Fix migration 001 - add base table creation for mtgonline_users, mtgonline_decklist_files, and mtgonline_rooms

This commit is contained in:
2026-08-08 05:23:39 +00:00
parent 1556a6d1aa
commit 6bb4034098
16 changed files with 498 additions and 89 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ prepend_sys_path = .
# This is useful for files that will be opened in Windows editors. # This is useful for files that will be opened in Windows editors.
output_encoding = utf-8 output_encoding = utf-8
sqlalchemy.url = postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline sqlalchemy.url = postgresql+asyncpg://mtgonline:mtgonline_pass@postgres:5432/mtgonline
[post_write_hooks] [post_write_hooks]
+1 -2
View File
@@ -67,8 +67,7 @@ async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async engine.""" """Run migrations in 'online' mode with async engine."""
connectable = async_engine_from_config( connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}), config.get_section(config.config_ini_section, {}),
options={"pool_pre_ping": True}, prefix="sqlalchemy.",
class_=pool.NullPool,
) )
async with connectable.connect() as connection: async with connectable.connect() as connection:
@@ -0,0 +1,93 @@
"""Create base tables for users, decklists, and rooms
Revision ID: 000
Revises:
Create Date: 2025-12-31 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '000'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create base tables: users, decklist files, and rooms."""
# 1. Users Table
op.create_table(
'mtgonline_users',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('username', sa.String(50), unique=True, nullable=False, index=True),
sa.Column('email', sa.String(255), unique=True, nullable=False, index=True),
sa.Column('password_hash', sa.String(255), nullable=False),
sa.Column('salt', sa.String(32), nullable=True),
sa.Column('display_name', sa.String(100), nullable=True),
sa.Column('avatar_url', sa.String(500), nullable=True),
sa.Column('country', sa.String(100), nullable=True),
sa.Column('real_name', sa.String(255), nullable=True),
sa.Column('avatar_bmp', sa.LargeBinary(), nullable=True),
sa.Column('privlevel', sa.Integer(), default=0),
sa.Column('is_active', sa.Boolean(), default=True),
sa.Column('is_banned', sa.Boolean(), default=False),
sa.Column('ban_reason', sa.Text(), nullable=True),
sa.Column('ban_ends', sa.DateTime(), nullable=True),
sa.Column('vip_status', sa.Boolean(), default=False),
sa.Column('vip_expiry', sa.DateTime(), nullable=True),
sa.Column('creation_date', sa.DateTime(), server_default=sa.func.now()),
sa.Column('last_login', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
# 2. Decklist Files Table
op.create_table(
'mtgonline_decklist_files',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('format', sa.String(50), server_default='standard'),
sa.Column('is_favorite', sa.Boolean(), default=False),
sa.Column('import_source', sa.String(50), nullable=True),
sa.Column('import_confidence', sa.Float(), nullable=True),
sa.Column('last_played', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index('idx_decklist_files_user', 'mtgonline_decklist_files', ['user_id'])
op.create_index('idx_decklist_files_name', 'mtgonline_decklist_files', ['name'])
# 3. Rooms Table
op.create_table(
'mtgonline_rooms',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('max_players', sa.Integer(), default=8),
sa.Column('is_public', sa.Boolean(), default=True),
sa.Column('is_password_protected', sa.Boolean(), default=False),
sa.Column('password_hash', sa.String(255), nullable=True),
sa.Column('game_type', sa.String(50), nullable=True),
sa.Column('format', sa.String(50), nullable=True),
sa.Column('created_by', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index('idx_rooms_created_by', 'mtgonline_rooms', ['created_by'])
op.create_index('idx_rooms_name', 'mtgonline_rooms', ['name'])
def downgrade() -> None:
"""Drop base tables."""
op.drop_table('mtgonline_rooms')
op.drop_table('mtgonline_decklist_files')
op.drop_table('mtgonline_users')
@@ -13,7 +13,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '001' revision: str = '001'
down_revision: Union[str, None] = None down_revision: Union[str, None] = '000'
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@@ -21,6 +21,69 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Create all user data tables.""" """Create all user data tables."""
# 0. Base Tables (Users and Decklist Files)
op.create_table(
'mtgonline_users',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('username', sa.String(50), unique=True, nullable=False, index=True),
sa.Column('email', sa.String(255), unique=True, nullable=False, index=True),
sa.Column('password_hash', sa.String(255), nullable=False),
sa.Column('salt', sa.String(32), nullable=True),
sa.Column('display_name', sa.String(100), nullable=True),
sa.Column('avatar_url', sa.String(500), nullable=True),
sa.Column('country', sa.String(100), nullable=True),
sa.Column('real_name', sa.String(255), nullable=True),
sa.Column('avatar_bmp', sa.LargeBinary(), nullable=True),
sa.Column('privlevel', sa.Integer(), default=0),
sa.Column('is_active', sa.Boolean(), default=True),
sa.Column('is_banned', sa.Boolean(), default=False),
sa.Column('ban_reason', sa.Text(), nullable=True),
sa.Column('ban_ends', sa.DateTime(), nullable=True),
sa.Column('vip_status', sa.Boolean(), default=False),
sa.Column('vip_expiry', sa.DateTime(), nullable=True),
sa.Column('creation_date', sa.DateTime(), server_default=sa.func.now()),
sa.Column('last_login', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_table(
'mtgonline_decklist_files',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('format', sa.String(50), server_default='standard'),
sa.Column('is_favorite', sa.Boolean(), default=False),
sa.Column('import_source', sa.String(50), nullable=True),
sa.Column('import_confidence', sa.Float(), nullable=True),
sa.Column('last_played', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index('idx_decklist_files_user', 'mtgonline_decklist_files', ['user_id'])
op.create_index('idx_decklist_files_name', 'mtgonline_decklist_files', ['name'])
# 0.1. Rooms Table
op.create_table(
'mtgonline_rooms',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('max_players', sa.Integer(), default=8),
sa.Column('is_public', sa.Boolean(), default=True),
sa.Column('is_password_protected', sa.Boolean(), default=False),
sa.Column('password_hash', sa.String(255), nullable=True),
sa.Column('game_type', sa.String(50), nullable=True),
sa.Column('format', sa.String(50), nullable=True),
sa.Column('created_by', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index('idx_rooms_created_by', 'mtgonline_rooms', ['created_by'])
op.create_index('idx_rooms_name', 'mtgonline_rooms', ['name'])
# 1. User Sessions Table # 1. User Sessions Table
op.create_table( op.create_table(
'user_sessions', 'user_sessions',
@@ -37,15 +100,7 @@ def upgrade() -> None:
op.create_index('idx_sessions_token', 'user_sessions', ['session_token_hash']) op.create_index('idx_sessions_token', 'user_sessions', ['session_token_hash'])
op.create_index('idx_sessions_expires', 'user_sessions', ['expires_at']) op.create_index('idx_sessions_expires', 'user_sessions', ['expires_at'])
# 2. Enhanced Decklist File columns # 2. Deck Versions Table
op.add_column('mtgonline_decklist_files', sa.Column('description', sa.Text(), nullable=True))
op.add_column('mtgonline_decklist_files', sa.Column('format', sa.String(50), server_default='standard'))
op.add_column('mtgonline_decklist_files', sa.Column('is_favorite', sa.Boolean(), default=False))
op.add_column('mtgonline_decklist_files', sa.Column('import_source', sa.String(50), nullable=True))
op.add_column('mtgonline_decklist_files', sa.Column('import_confidence', sa.Float(), nullable=True))
op.add_column('mtgonline_decklist_files', sa.Column('last_played', sa.DateTime(), nullable=True))
# 3. Deck Versions Table
op.create_table( op.create_table(
'deck_versions', 'deck_versions',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True), sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True),
@@ -272,10 +327,7 @@ def downgrade() -> None:
op.drop_table('deck_versions') op.drop_table('deck_versions')
op.drop_table('user_sessions') op.drop_table('user_sessions')
# Drop added columns from existing table # Drop base tables (must be dropped after tables that reference them)
op.drop_column('mtgonline_decklist_files', 'last_played') op.drop_table('mtgonline_rooms')
op.drop_column('mtgonline_decklist_files', 'import_confidence') op.drop_table('mtgonline_decklist_files')
op.drop_column('mtgonline_decklist_files', 'import_source') op.drop_table('mtgonline_users')
op.drop_column('mtgonline_decklist_files', 'is_favorite')
op.drop_column('mtgonline_decklist_files', 'format')
op.drop_column('mtgonline_decklist_files', 'description')
+2 -2
View File
@@ -19,10 +19,10 @@ class Settings(BaseSettings):
JWT_SECRET_KEY: str = "change-me-in-production" JWT_SECRET_KEY: str = "change-me-in-production"
# Database - Primary (mtgonline app) # Database - Primary (mtgonline app)
DATABASE_URL: str = "postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline" DATABASE_URL: str = "postgresql+asyncpg://mtgonline:mtgonline_pass@postgres:5432/mtgonline"
# Database - Secondary (mtgjson data) # Database - Secondary (mtgjson data)
MTG_DATABASE_URL: str = "postgresql+asyncpg://mtgonline_user:mtgonline_password@mtgdata:5432/mtgdata" MTG_DATABASE_URL: str = "postgresql+asyncpg://mtgonline:mtgonline_pass@mtgdata:5432/mtgdata"
# Redis # Redis
REDIS_URL: str = "redis://redis:6379/0" REDIS_URL: str = "redis://redis:6379/0"
+1 -21
View File
@@ -1,5 +1,5 @@
"""SQLAlchemy ORM model for card import batches.""" """SQLAlchemy ORM model for card import batches."""
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON, Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.core.database import Base from app.core.database import Base
@@ -34,23 +34,3 @@ class CardImportBatch(Base):
return f"<CardImportBatch id={self.id} user={self.user_id} status={self.status}>" return f"<CardImportBatch id={self.id} user={self.user_id} status={self.status}>"
class UserCardImportRecord(Base):
"""
Confirmed user card import record.
Stores the confirmed state of an imported card collection.
"""
__tablename__ = "user_card_imports_confirmed"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True)
batch_id = Column(Integer, ForeignKey("card_import_batches.id", ondelete="CASCADE"), nullable=False, index=True)
is_confirmed = Column(Boolean, nullable=False, default=True)
confirmed_at = Column(DateTime, server_default=func.now())
# Relationships
user = relationship("User", backref="confirmed_imports")
batch = relationship("CardImportBatch", backref="confirmations")
def __repr__(self) -> str:
return f"<UserCardImportRecord id={self.id} user={self.user_id} confirmed={self.is_confirmed}>"
+1 -1
View File
@@ -16,7 +16,7 @@ from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
from app.models.card_import_batch import CardImportBatch from app.models.card_import_batch import CardImportBatch
from app.models.user_card_import_record import UserCardImportRecord from app.models.user_card_import_record import UserCardImportRecord
from app.models.user_card_collection import UserCardCollection from app.models.user_data import UserCardCollection
from app.models.models import MtgonlineCard from app.models.models import MtgonlineCard
from app.models.user_deck import UserDeck, UserDeckCard from app.models.user_deck import UserDeck, UserDeckCard
from app.services.file_parser import FileParser from app.services.file_parser import FileParser
+1 -1
View File
@@ -1174,7 +1174,7 @@ async def add_group_member(
return {"message": "Member added", "member_id": member.id} return {"message": "Member added", "member_id": member.id}
@router.patch("/groups/{group_id}/members/{member_id}", status_model=GroupMemberUpdate) @router.patch("/groups/{group_id}/members/{member_id}", response_model=GroupMemberUpdate)
async def update_group_member( async def update_group_member(
group_id: int, group_id: int,
member_id: int, member_id: int,
+159
View File
@@ -0,0 +1,159 @@
"""
Pydantic schemas for user card collection features.
Covers card collection CRUD operations, wishlist management, and collection statistics.
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
# ===== Enum Types =====
class CardCondition(str, Enum):
"""Card condition ratings."""
NEAR_MINT = "NEAR_MINT"
LIGHTLY_PLAYED = "LIGHTLY_PLAYED"
MODERATELY_PLAYED = "MODERATELY_PLAYED"
HEAVILY_PLAYED = "HEAVILY_PLAYED"
DAMAGED = "DAMAGED"
class AcquisitionMethod(str, Enum):
"""How a card was acquired."""
PACK_OPENING = "PACK_OPENING"
TRADE = "TRADE"
PURCHASE = "PURCHASE"
GIFT = "GIFT"
CONTEST = "CONTEST"
OTHER = "OTHER"
# ===== Card Collection Schemas =====
class CardCollectionCreate(BaseModel):
"""Card collection item creation request."""
card_id: int
quantity: int = Field(1, ge=1)
condition: CardCondition = CardCondition.NEAR_MINT
language: str = Field("EN", max_length=5)
is_foil: bool = False
is_alt_art: bool = False
acquired_date: Optional[datetime] = None
acquisition_method: Optional[AcquisitionMethod] = None
notes: Optional[str] = None
class CardCollectionUpdate(BaseModel):
"""Card collection item update request."""
quantity: Optional[int] = None
condition: Optional[CardCondition] = None
language: Optional[str] = None
is_foil: Optional[bool] = None
is_alt_art: Optional[bool] = None
acquired_date: Optional[datetime] = None
acquisition_method: Optional[AcquisitionMethod] = None
notes: Optional[str] = None
class CardCollectionResponse(BaseModel):
"""Card collection item response."""
id: int
user_id: int
card_id: int
quantity: int
condition: str
language: str
is_foil: bool
is_alt_art: bool
acquired_date: Optional[datetime]
acquisition_method: Optional[str]
notes: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class CardCollectionListResponse(BaseModel):
"""List of user card collection."""
cards: List[CardCollectionResponse]
total: int
page: int
page_size: int
total_pages: int
# ===== Wishlist Schemas =====
class WishlistCreate(BaseModel):
"""Wishlist item creation request."""
card_id: int
max_price: Optional[float] = None
notes: Optional[str] = None
class WishlistUpdate(BaseModel):
"""Wishlist item update request."""
max_price: Optional[float] = None
notes: Optional[str] = None
class WishlistResponse(BaseModel):
"""Wishlist item response."""
id: int
user_id: int
card_id: int
max_price: Optional[float]
notes: Optional[str]
created_at: datetime
class Config:
from_attributes = True
class WishlistListResponse(BaseModel):
"""List of wishlist items."""
items: List[WishlistResponse]
total: int
# ===== Collection Statistics Schemas =====
class CollectionStatistics(BaseModel):
"""User collection statistics summary."""
total_cards: int
unique_cards: int
total_quantity: int
foil_count: int
alt_art_count: int
condition_breakdown: Dict[str, int]
language_breakdown: Dict[str, int]
acquisition_breakdown: Dict[str, int]
class CollectionSummaryResponse(BaseModel):
"""Collection summary with statistics."""
statistics: CollectionStatistics
recent_acquisitions: List[CardCollectionResponse]
top_cards: List[CardCollectionResponse]
# ===== Generic Response Schemas =====
class MessageResponse(BaseModel):
"""Generic message response."""
message: str
class CountResponse(BaseModel):
"""Generic count response."""
count: int
class ErrorDetail(BaseModel):
"""Error detail."""
error: str
detail: str
+30 -8
View File
@@ -1,11 +1,11 @@
"""Services package initialization.""" """Services package initialization."""
from app.services.deck_parser import DeckParser from app.services.deck_parser import DeckParser
from app.services.card_database import search_cards, get_card_by_name, get_cards_by_set, get_card_types, get_card_rarities, get_sets, get_set_by_code, get_card_statistics from app.services.card_database import search_cards, get_card_by_name, get_cards_by_set, get_card_types, get_card_rarities, get_sets, get_set_by_code, get_card_statistics
from app.services.card_mirror_service import CardMirrorService from app.services.card_mirror_service import upsert_card_mirror, get_card_mirror_by_name, search_card_mirrors, add_card_to_deck, remove_card_from_deck, get_deck_cards, get_user_deck_summaries, sync_mirrors_from_mtg_cards, get_card_statistics
from app.services.mtgjson_manager import MTGJSONManager, get_manager from app.services.mtgjson_manager import MTGJSONManager, get_manager
from app.services.mtgjson_downloader import MTGJSONDownloader from app.services.mtgjson_downloader import download_all_files, verify_downloads, get_file_list
from app.services.mtgjson_loader import MTGJSONLoader from app.services.mtgjson_loader import create_tables, download_file, extract_zip, get_all_printings_psql_file, parse_psql_file, import_cards, import_all_printings_psql, import_all_set_files, import_all_identifiers, import_all_deck_files, import_simple_json, create_indexes, show_summary, main
from app.services.mtgjson_uploader import MTGJSONUploader from app.services.mtgjson_uploader import create_tables, import_all_printings_psql, import_all_set_files, import_all_identifiers, import_all_deck_files, import_json_files, download_file, extract_zip, main
from app.services.file_parser import FileParser from app.services.file_parser import FileParser
from app.services.fuzzy_card_matcher import FuzzyCardMatcher from app.services.fuzzy_card_matcher import FuzzyCardMatcher
from app.services.import_batch_processor import ImportBatchProcessor from app.services.import_batch_processor import ImportBatchProcessor
@@ -23,12 +23,34 @@ __all__ = [
"get_sets", "get_sets",
"get_set_by_code", "get_set_by_code",
"get_card_statistics", "get_card_statistics",
"CardMirrorService", "upsert_card_mirror",
"get_card_mirror_by_name",
"search_card_mirrors",
"add_card_to_deck",
"remove_card_from_deck",
"get_deck_cards",
"get_user_deck_summaries",
"sync_mirrors_from_mtg_cards",
"MTGJSONManager", "MTGJSONManager",
"get_manager", "get_manager",
"MTGJSONDownloader", "download_all_files",
"MTGJSONLoader", "verify_downloads",
"MTGJSONUploader", "get_file_list",
"create_tables",
"download_file",
"extract_zip",
"get_all_printings_psql_file",
"parse_psql_file",
"import_cards",
"import_all_printings_psql",
"import_all_set_files",
"import_all_identifiers",
"import_all_deck_files",
"import_simple_json",
"import_json_files",
"create_indexes",
"show_summary",
"main",
"FileParser", "FileParser",
"FuzzyCardMatcher", "FuzzyCardMatcher",
"ImportBatchProcessor", "ImportBatchProcessor",
@@ -8,7 +8,7 @@ from sqlalchemy.sql import func
from app.models.card_import_batch import CardImportBatch from app.models.card_import_batch import CardImportBatch
from app.models.user_card_import_record import UserCardImportRecord from app.models.user_card_import_record import UserCardImportRecord
from app.models.user_card_collection import UserCardCollection from app.models.user_data import UserCardCollection
from app.models.models import MtgonlineCard from app.models.models import MtgonlineCard
from app.services.fuzzy_card_matcher import FuzzyCardMatcher from app.services.fuzzy_card_matcher import FuzzyCardMatcher
+115 -1
View File
@@ -22,11 +22,125 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy import text, insert, update, select, and_ from sqlalchemy import text, insert, update, select, and_, Table, MetaData, Column, Integer, String, Text, Boolean, DateTime, Date, ForeignKey
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.core.settings import get_settings from app.core.settings import get_settings
# Define table metadata
metadata = MetaData()
# Define table objects
mtg_sets_table = Table('mtg_sets', metadata,
Column('id', Integer, primary_key=True),
Column('code', String(10), unique=True, nullable=False),
Column('name', String(255)),
Column('type', String(100)),
Column('release_date', Date),
Column('base_set_size', Integer),
Column('total_size', Integer),
Column('is_foil_only', Boolean),
Column('is_non_foil_only', Boolean),
Column('digital', Boolean),
Column('icon_svg_url', Text),
Column('parent_code', String(10)),
Column('mtgo_code', String(10)),
Column('card_count', Integer),
Column('image_url', Text),
Column('created_at', DateTime, default=datetime.utcnow),
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
)
mtg_cards_table = Table('mtg_cards', metadata,
Column('id', Integer, primary_key=True),
Column('set_id', Integer, ForeignKey('mtg_sets.id')),
Column('name', String(255)),
Column('mana_cost', String(255)),
Column('type_line', String(255)),
Column('oracle_text', Text),
Column('power', String(50)),
Column('toughness', String(50)),
Column('rarity', String(50)),
Column('layout', String(50)),
Column('artist', String(255)),
Column('flavor_text', Text),
Column('numbers', String(100)),
Column('identifiers', Text),
Column('images', Text),
Column('created_at', DateTime, default=datetime.utcnow),
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
)
card_identifiers_table = Table('card_identifiers', metadata,
Column('id', Integer, primary_key=True),
Column('uuid', String, unique=True, nullable=False),
Column('name', String(255)),
Column('mana_cost', String(255)),
Column('type_line', String(255)),
Column('oracle_text', Text),
Column('power', String(50)),
Column('toughness', String(50)),
Column('rarity', String(50)),
Column('layout', String(50)),
Column('artist', String(255)),
Column('flavor_text', Text),
Column('set_code', String(10)),
Column('created_at', DateTime, default=datetime.utcnow),
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
)
decks_table = Table('decks', metadata,
Column('id', Integer, primary_key=True),
Column('name', String(255), nullable=False),
Column('description', Text),
Column('format', String(50)),
Column('command', Text),
Column('commander', Text),
Column('creation_date', DateTime, default=datetime.utcnow),
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
)
card_types_table = Table('card_types', metadata,
Column('id', Integer, primary_key=True),
Column('type', String(100), unique=True, nullable=False),
Column('description', Text),
Column('created_at', DateTime, default=datetime.utcnow),
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
)
deck_list_table = Table('deck_list', metadata,
Column('id', Integer, primary_key=True),
Column('deck_id', String(100), unique=True, nullable=False),
Column('name', String(255)),
Column('description', Text),
Column('format', String(50)),
Column('command', Text),
Column('commander', Text),
Column('total_cards', Integer),
Column('created_at', DateTime, default=datetime.utcnow),
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
)
card_keywords_table = Table('card_keywords', metadata,
Column('id', Integer, primary_key=True),
Column('keyword', String(100), unique=True, nullable=False),
Column('description', Text),
Column('created_at', DateTime, default=datetime.utcnow),
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
)
set_list_table = Table('set_list', metadata,
Column('id', Integer, primary_key=True),
Column('set_code', String(10), unique=True, nullable=False),
Column('set_name', String(255)),
Column('set_type', String(100)),
Column('release_date', Date),
Column('base_set_size', Integer),
Column('total_size', Integer),
Column('created_at', DateTime, default=datetime.utcnow),
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
)
# MTGJSON API v5 base URL # MTGJSON API v5 base URL
MTGJSON_API_V5 = "https://mtgjson.com/api/v5" MTGJSON_API_V5 = "https://mtgjson.com/api/v5"
+1 -1
View File
@@ -12,7 +12,7 @@ import asyncio
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
async def check(): async def check():
engine = create_async_engine('postgresql+asyncpg://mtgonline_user:mtgonline_password@postgres:5432/mtgonline') engine = create_async_engine('postgresql+asyncpg://mtgonline:mtgonline_pass@postgres:5432/mtgonline')
async with engine.connect() as conn: async with engine.connect() as conn:
await conn.execute(sqlalchemy.text('SELECT 1')) await conn.execute(sqlalchemy.text('SELECT 1'))
await engine.dispose() await engine.dispose()
+7 -7
View File
@@ -15,7 +15,7 @@ class TestAdminEndpoints:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_users_admin(self, client: AsyncClient, admin_headers: dict): async def test_list_users_admin(self, client: AsyncClient, admin_headers: dict):
"""Test listing users as admin.""" """Test listing users as admin."""
response = await client.get("/api/v1/admin/users", headers=admin_headers) response = await client.get("/admin/users", headers=admin_headers)
print(f"\nDEBUG: status={response.status_code}, body={response.text}") print(f"\nDEBUG: status={response.status_code}, body={response.text}")
if response.status_code != 200: if response.status_code != 200:
print(f"DEBUG: response.json()={response.json()}") print(f"DEBUG: response.json()={response.json()}")
@@ -26,14 +26,14 @@ class TestAdminEndpoints:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_users_non_admin(self, client: AsyncClient, auth_headers: dict): async def test_list_users_non_admin(self, client: AsyncClient, auth_headers: dict):
"""Test listing users as non-admin.""" """Test listing users as non-admin."""
response = await client.get("/api/v1/admin/users", headers=auth_headers) response = await client.get("/admin/users", headers=auth_headers)
assert response.status_code == 403 assert response.status_code == 403
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_ban(self, client: AsyncClient, admin_headers: dict, test_user: User): async def test_create_ban(self, client: AsyncClient, admin_headers: dict, test_user: User):
"""Test creating a ban.""" """Test creating a ban."""
response = await client.post( response = await client.post(
"/api/v1/admin/bans", "/admin/bans",
json={ json={
"user_id": test_user.id, "user_id": test_user.id,
"reason": "Test ban reason", "reason": "Test ban reason",
@@ -50,7 +50,7 @@ class TestAdminEndpoints:
"""Test listing bans.""" """Test listing bans."""
# Create a ban first # Create a ban first
await client.post( await client.post(
"/api/v1/admin/bans", "/admin/bans",
json={ json={
"user_id": test_user.id, "user_id": test_user.id,
"reason": "Test ban", "reason": "Test ban",
@@ -59,7 +59,7 @@ class TestAdminEndpoints:
) )
# List bans # List bans
response = await client.get("/api/v1/admin/bans", headers=admin_headers) response = await client.get("/admin/bans", headers=admin_headers)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert isinstance(data, list) assert isinstance(data, list)
@@ -70,7 +70,7 @@ class TestAdminEndpoints:
"""Test unbanning a user.""" """Test unbanning a user."""
# Create a ban first # Create a ban first
ban_response = await client.post( ban_response = await client.post(
"/api/v1/admin/bans", "/admin/bans",
json={ json={
"user_id": test_user.id, "user_id": test_user.id,
"reason": "Test ban", "reason": "Test ban",
@@ -81,7 +81,7 @@ class TestAdminEndpoints:
# Unban user # Unban user
response = await client.post( response = await client.post(
f"/api/v1/admin/bans/{ban_id}/unban", f"/admin/bans/{ban_id}/unban",
headers=admin_headers, headers=admin_headers,
) )
assert response.status_code == 200 assert response.status_code == 200
+8 -8
View File
@@ -16,7 +16,7 @@ class TestAuthentication:
async def test_login_success(self, client: AsyncClient, test_user: User): async def test_login_success(self, client: AsyncClient, test_user: User):
"""Test successful login.""" """Test successful login."""
response = await client.post( response = await client.post(
"/api/v1/auth/login", "/auth/login",
json={ json={
"username": "regular_test", "username": "regular_test",
"password": "testpassword123", "password": "testpassword123",
@@ -32,7 +32,7 @@ class TestAuthentication:
async def test_login_invalid_password(self, client: AsyncClient, test_user: User): async def test_login_invalid_password(self, client: AsyncClient, test_user: User):
"""Test login with invalid password.""" """Test login with invalid password."""
response = await client.post( response = await client.post(
"/api/v1/auth/login", "/auth/login",
json={ json={
"username": "regular_test", "username": "regular_test",
"password": "wrongpassword", "password": "wrongpassword",
@@ -45,7 +45,7 @@ class TestAuthentication:
async def test_login_nonexistent_user(self, client: AsyncClient): async def test_login_nonexistent_user(self, client: AsyncClient):
"""Test login with non-existent user.""" """Test login with non-existent user."""
response = await client.post( response = await client.post(
"/api/v1/auth/login", "/auth/login",
json={ json={
"username": "nonexistent", "username": "nonexistent",
"password": "password123", "password": "password123",
@@ -58,7 +58,7 @@ class TestAuthentication:
async def test_register_success(self, client: AsyncClient): async def test_register_success(self, client: AsyncClient):
"""Test successful user registration.""" """Test successful user registration."""
response = await client.post( response = await client.post(
"/api/v1/auth/register", "/auth/register",
json={ json={
"username": "newuser", "username": "newuser",
"password": "newpassword123", "password": "newpassword123",
@@ -75,7 +75,7 @@ class TestAuthentication:
async def test_register_duplicate_username(self, client: AsyncClient, test_user: User): async def test_register_duplicate_username(self, client: AsyncClient, test_user: User):
"""Test registration with duplicate username.""" """Test registration with duplicate username."""
response = await client.post( response = await client.post(
"/api/v1/auth/register", "/auth/register",
json={ json={
"username": "regular_test", # Already exists "username": "regular_test", # Already exists
"password": "newpassword123", "password": "newpassword123",
@@ -89,7 +89,7 @@ class TestAuthentication:
async def test_get_current_user(self, client: AsyncClient, regular_token: str): async def test_get_current_user(self, client: AsyncClient, regular_token: str):
"""Test getting current user.""" """Test getting current user."""
response = await client.get( response = await client.get(
"/api/v1/auth/me", "/auth/me",
params={"token": regular_token}, params={"token": regular_token},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -103,7 +103,7 @@ class TestAuthentication:
# First login to get refresh token # First login to get refresh token
login_response = await client.post( login_response = await client.post(
"/api/v1/auth/login", "/auth/login",
json={ json={
"username": "regular_test", "username": "regular_test",
"password": "testpassword123", "password": "testpassword123",
@@ -113,7 +113,7 @@ class TestAuthentication:
# Refresh token # Refresh token
response = await client.post( response = await client.post(
"/api/v1/auth/refresh", "/auth/refresh",
json={ json={
"refresh_token": refresh_token, "refresh_token": refresh_token,
}, },
+8 -18
View File
@@ -11,20 +11,7 @@
"phase": 2, "phase": 2,
"status": "completed", "status": "completed",
"description": "Card import feature with fuzzy matching, deck builder service, and API endpoints", "description": "Card import feature with fuzzy matching, deck builder service, and API endpoints",
"key_deliverables": [ "key_deliverables": ["Card import router with status/import/delete/summary endpoints", "Fuzzy matching logic (exact, case-insensitive, partial)", "Card search endpoint integration", "Pydantic schemas for import operations", "Card import model with CASCADE FK", "Deck CRUD endpoints (list, create, get, update, delete)", "Card management endpoints (add, update, remove, list cards)", "Deck finalize endpoint (DRAFT → FINAL transition)", "Precedent endpoints (list, create, get, use/clone)", "Card search endpoint (POST /decks/search/cards)", "Suggestion endpoints (list, add suggestions)", "Pydantic schemas for all deckbuilding operations"]
"Card import router with status/import/delete/summary endpoints",
"Fuzzy matching logic (exact, case-insensitive, partial)",
"Card search endpoint integration",
"Pydantic schemas for import operations",
"Card import model with CASCADE FK",
"Deck CRUD endpoints (list, create, get, update, delete)",
"Card management endpoints (add, update, remove, list cards)",
"Deck finalize endpoint (DRAFT → FINAL transition)",
"Precedent endpoints (list, create, get, use/clone)",
"Card search endpoint (POST /decks/search/cards)",
"Suggestion endpoints (list, add suggestions)",
"Pydantic schemas for all deckbuilding operations"
]
}, },
{ {
"phase": 3, "phase": 3,
@@ -59,7 +46,7 @@
}, },
"architectural_notes": "Dual database setup: mtgonline for app data, mtgdata for MTGJSON card data. Alembic migrations run on container startup. Async SQLAlchemy with asyncpg driver. Card mirrors in mtgo_platform for fast deckbuilding queries. User data API mounted at /api/v1/user-data. Card import router mounted at /api/v1/card-import. Phase 3 adds server-authoritative game engine with WebSocket real-time multiplayer, 11-phase MTG turn structure, stack resolution, card zones, deck validation, and replay recording. MTG rules engine integrated in backend/mtg_rules_engine/.", "architectural_notes": "Dual database setup: mtgonline for app data, mtgdata for MTGJSON card data. Alembic migrations run on container startup. Async SQLAlchemy with asyncpg driver. Card mirrors in mtgo_platform for fast deckbuilding queries. User data API mounted at /api/v1/user-data. Card import router mounted at /api/v1/card-import. Phase 3 adds server-authoritative game engine with WebSocket real-time multiplayer, 11-phase MTG turn structure, stack resolution, card zones, deck validation, and replay recording. MTG rules engine integrated in backend/mtg_rules_engine/.",
"task_description": "Multiplayer game server — Phase 3 handoff. Architecture derived from Cockatrice analysis (v3.1.0 Graduation Day). Server-authoritative game engine with WebSocket real-time multiplayer, 11-phase MTG turn structure, stack resolution, card zones, deck validation, and replay recording. Handoff document at handoff.md provides complete blueprint.", "task_description": "Multiplayer game server — Phase 3 handoff. Architecture derived from Cockatrice analysis (v3.1.0 Graduation Day). Server-authoritative game engine with WebSocket real-time multiplayer, 11-phase MTG turn structure, stack resolution, card zones, deck validation, and replay recording. Handoff document at handoff.md provides complete blueprint.",
"current_step": "Phase 2 complete. MTG rules engine integrated in backend/mtg_rules_engine/. Ready to begin Phase 3: Multiplayer game server implementation.", "current_step": "Phase 2 complete. Migration 001 fixed - added base table creation for mtgonline_users, mtgonline_decklist_files, and mtgonline_rooms. Ready to begin Phase 3: Multiplayer game server implementation.",
"files_created": [ "files_created": [
"alembic.ini", "alembic.ini",
"alembic/env.py", "alembic/env.py",
@@ -93,7 +80,10 @@
"backend/mtg_rules_engine/update_check.py", "backend/mtg_rules_engine/update_check.py",
"backend/mtg_rules_engine/test_engine.py", "backend/mtg_rules_engine/test_engine.py",
"backend/mtg_rules_engine/__init__.py", "backend/mtg_rules_engine/__init__.py",
"backend/mtg_rules_engine/README.md" "backend/mtg_rules_engine/README.md",
"scripts/read_card_list.py",
"scripts/test_card_import.py",
"backend/app/schemas/user_card_collection.py"
], ],
"files_modified": [ "files_modified": [
"app/models/__init__.py", "app/models/__init__.py",
@@ -134,6 +124,6 @@
"Update documentation and push to Gitea" "Update documentation and push to Gitea"
], ],
"blockers": [], "blockers": [],
"commit_hash": "165a6f1", "commit_hash": "1556a6d",
"timestamp": "2026-07-25T19:58:00-04:00" "timestamp": "2026-07-26T18:32:10-04:00"
} }