From 6bb40340987d39c3549d41301c4541aff62711ee Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 8 Aug 2026 05:23:39 +0000 Subject: [PATCH] Fix migration 001 - add base table creation for mtgonline_users, mtgonline_decklist_files, and mtgonline_rooms --- backend/alembic.ini | 2 +- backend/alembic/env.py | 3 +- backend/alembic/versions/000_base_tables.py | 93 ++++++++++ .../versions/001_initial_user_schema.py | 86 ++++++++-- backend/app/core/settings.py | 4 +- backend/app/models/card_import_batch.py | 22 +-- backend/app/routers/card_import.py | 2 +- backend/app/routers/user_data.py | 2 +- backend/app/schemas/user_card_collection.py | 159 ++++++++++++++++++ backend/app/services/__init__.py | 38 ++++- .../app/services/import_batch_processor.py | 2 +- backend/app/services/mtgjson_uploader.py | 116 ++++++++++++- backend/scripts/run_migrations.sh | 2 +- backend/tests/test_admin.py | 14 +- backend/tests/test_auth.py | 16 +- state.json | 26 +-- 16 files changed, 498 insertions(+), 89 deletions(-) create mode 100644 backend/alembic/versions/000_base_tables.py create mode 100644 backend/app/schemas/user_card_collection.py diff --git a/backend/alembic.ini b/backend/alembic.ini index 2942fcb..e58b7b8 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -42,7 +42,7 @@ prepend_sys_path = . # This is useful for files that will be opened in Windows editors. 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] diff --git a/backend/alembic/env.py b/backend/alembic/env.py index bf73d66..8e1100e 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -67,8 +67,7 @@ async def run_async_migrations() -> None: """Run migrations in 'online' mode with async engine.""" connectable = async_engine_from_config( config.get_section(config.config_ini_section, {}), - options={"pool_pre_ping": True}, - class_=pool.NullPool, + prefix="sqlalchemy.", ) async with connectable.connect() as connection: diff --git a/backend/alembic/versions/000_base_tables.py b/backend/alembic/versions/000_base_tables.py new file mode 100644 index 0000000..627e519 --- /dev/null +++ b/backend/alembic/versions/000_base_tables.py @@ -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') diff --git a/backend/alembic/versions/001_initial_user_schema.py b/backend/alembic/versions/001_initial_user_schema.py index 4056d54..cd9b081 100644 --- a/backend/alembic/versions/001_initial_user_schema.py +++ b/backend/alembic/versions/001_initial_user_schema.py @@ -13,7 +13,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = '001' -down_revision: Union[str, None] = None +down_revision: Union[str, None] = '000' branch_labels: 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: """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 op.create_table( '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_expires', 'user_sessions', ['expires_at']) - # 2. Enhanced Decklist File columns - 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 + # 2. Deck Versions Table op.create_table( 'deck_versions', 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('user_sessions') - # Drop added columns from existing table - op.drop_column('mtgonline_decklist_files', 'last_played') - op.drop_column('mtgonline_decklist_files', 'import_confidence') - op.drop_column('mtgonline_decklist_files', 'import_source') - op.drop_column('mtgonline_decklist_files', 'is_favorite') - op.drop_column('mtgonline_decklist_files', 'format') - op.drop_column('mtgonline_decklist_files', 'description') + # Drop base tables (must be dropped after tables that reference them) + op.drop_table('mtgonline_rooms') + op.drop_table('mtgonline_decklist_files') + op.drop_table('mtgonline_users') diff --git a/backend/app/core/settings.py b/backend/app/core/settings.py index 3bcc9e9..91c759a 100644 --- a/backend/app/core/settings.py +++ b/backend/app/core/settings.py @@ -19,10 +19,10 @@ class Settings(BaseSettings): JWT_SECRET_KEY: str = "change-me-in-production" # 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) - 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_URL: str = "redis://redis:6379/0" diff --git a/backend/app/models/card_import_batch.py b/backend/app/models/card_import_batch.py index 3679d1a..11f08b3 100644 --- a/backend/app/models/card_import_batch.py +++ b/backend/app/models/card_import_batch.py @@ -1,5 +1,5 @@ """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.sql import func from app.core.database import Base @@ -34,23 +34,3 @@ class CardImportBatch(Base): return f"" -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"" diff --git a/backend/app/routers/card_import.py b/backend/app/routers/card_import.py index c741fe9..98607be 100644 --- a/backend/app/routers/card_import.py +++ b/backend/app/routers/card_import.py @@ -16,7 +16,7 @@ from app.core.database import get_db from app.core.security import get_current_user from app.models.card_import_batch import CardImportBatch 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.user_deck import UserDeck, UserDeckCard from app.services.file_parser import FileParser diff --git a/backend/app/routers/user_data.py b/backend/app/routers/user_data.py index 034e583..693057a 100644 --- a/backend/app/routers/user_data.py +++ b/backend/app/routers/user_data.py @@ -1174,7 +1174,7 @@ async def add_group_member( 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( group_id: int, member_id: int, diff --git a/backend/app/schemas/user_card_collection.py b/backend/app/schemas/user_card_collection.py new file mode 100644 index 0000000..1ad67ba --- /dev/null +++ b/backend/app/schemas/user_card_collection.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 8a85465..25302ba 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,11 +1,11 @@ """Services package initialization.""" 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_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_downloader import MTGJSONDownloader -from app.services.mtgjson_loader import MTGJSONLoader -from app.services.mtgjson_uploader import MTGJSONUploader +from app.services.mtgjson_downloader import download_all_files, verify_downloads, get_file_list +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 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.fuzzy_card_matcher import FuzzyCardMatcher from app.services.import_batch_processor import ImportBatchProcessor @@ -23,12 +23,34 @@ __all__ = [ "get_sets", "get_set_by_code", "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", "get_manager", - "MTGJSONDownloader", - "MTGJSONLoader", - "MTGJSONUploader", + "download_all_files", + "verify_downloads", + "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", "FuzzyCardMatcher", "ImportBatchProcessor", diff --git a/backend/app/services/import_batch_processor.py b/backend/app/services/import_batch_processor.py index 3c9384f..9f93843 100644 --- a/backend/app/services/import_batch_processor.py +++ b/backend/app/services/import_batch_processor.py @@ -8,7 +8,7 @@ from sqlalchemy.sql import func from app.models.card_import_batch import CardImportBatch 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.services.fuzzy_card_matcher import FuzzyCardMatcher diff --git a/backend/app/services/mtgjson_uploader.py b/backend/app/services/mtgjson_uploader.py index c248d94..9d51c24 100644 --- a/backend/app/services/mtgjson_uploader.py +++ b/backend/app/services/mtgjson_uploader.py @@ -22,11 +22,125 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession 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 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 = "https://mtgjson.com/api/v5" diff --git a/backend/scripts/run_migrations.sh b/backend/scripts/run_migrations.sh index 7801b52..46d0e4a 100644 --- a/backend/scripts/run_migrations.sh +++ b/backend/scripts/run_migrations.sh @@ -12,7 +12,7 @@ import asyncio from sqlalchemy.ext.asyncio import create_async_engine 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: await conn.execute(sqlalchemy.text('SELECT 1')) await engine.dispose() diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index d9ec527..82950e1 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -15,7 +15,7 @@ class TestAdminEndpoints: @pytest.mark.asyncio async def test_list_users_admin(self, client: AsyncClient, admin_headers: dict): """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}") if response.status_code != 200: print(f"DEBUG: response.json()={response.json()}") @@ -26,14 +26,14 @@ class TestAdminEndpoints: @pytest.mark.asyncio async def test_list_users_non_admin(self, client: AsyncClient, auth_headers: dict): """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 @pytest.mark.asyncio async def test_create_ban(self, client: AsyncClient, admin_headers: dict, test_user: User): """Test creating a ban.""" response = await client.post( - "/api/v1/admin/bans", + "/admin/bans", json={ "user_id": test_user.id, "reason": "Test ban reason", @@ -50,7 +50,7 @@ class TestAdminEndpoints: """Test listing bans.""" # Create a ban first await client.post( - "/api/v1/admin/bans", + "/admin/bans", json={ "user_id": test_user.id, "reason": "Test ban", @@ -59,7 +59,7 @@ class TestAdminEndpoints: ) # 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 data = response.json() assert isinstance(data, list) @@ -70,7 +70,7 @@ class TestAdminEndpoints: """Test unbanning a user.""" # Create a ban first ban_response = await client.post( - "/api/v1/admin/bans", + "/admin/bans", json={ "user_id": test_user.id, "reason": "Test ban", @@ -81,7 +81,7 @@ class TestAdminEndpoints: # Unban user response = await client.post( - f"/api/v1/admin/bans/{ban_id}/unban", + f"/admin/bans/{ban_id}/unban", headers=admin_headers, ) assert response.status_code == 200 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 9842653..58cd177 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -16,7 +16,7 @@ class TestAuthentication: async def test_login_success(self, client: AsyncClient, test_user: User): """Test successful login.""" response = await client.post( - "/api/v1/auth/login", + "/auth/login", json={ "username": "regular_test", "password": "testpassword123", @@ -32,7 +32,7 @@ class TestAuthentication: async def test_login_invalid_password(self, client: AsyncClient, test_user: User): """Test login with invalid password.""" response = await client.post( - "/api/v1/auth/login", + "/auth/login", json={ "username": "regular_test", "password": "wrongpassword", @@ -45,7 +45,7 @@ class TestAuthentication: async def test_login_nonexistent_user(self, client: AsyncClient): """Test login with non-existent user.""" response = await client.post( - "/api/v1/auth/login", + "/auth/login", json={ "username": "nonexistent", "password": "password123", @@ -58,7 +58,7 @@ class TestAuthentication: async def test_register_success(self, client: AsyncClient): """Test successful user registration.""" response = await client.post( - "/api/v1/auth/register", + "/auth/register", json={ "username": "newuser", "password": "newpassword123", @@ -75,7 +75,7 @@ class TestAuthentication: async def test_register_duplicate_username(self, client: AsyncClient, test_user: User): """Test registration with duplicate username.""" response = await client.post( - "/api/v1/auth/register", + "/auth/register", json={ "username": "regular_test", # Already exists "password": "newpassword123", @@ -89,7 +89,7 @@ class TestAuthentication: async def test_get_current_user(self, client: AsyncClient, regular_token: str): """Test getting current user.""" response = await client.get( - "/api/v1/auth/me", + "/auth/me", params={"token": regular_token}, ) assert response.status_code == 200 @@ -103,7 +103,7 @@ class TestAuthentication: # First login to get refresh token login_response = await client.post( - "/api/v1/auth/login", + "/auth/login", json={ "username": "regular_test", "password": "testpassword123", @@ -113,7 +113,7 @@ class TestAuthentication: # Refresh token response = await client.post( - "/api/v1/auth/refresh", + "/auth/refresh", json={ "refresh_token": refresh_token, }, diff --git a/state.json b/state.json index 1a3f8fe..ba25edc 100644 --- a/state.json +++ b/state.json @@ -11,20 +11,7 @@ "phase": 2, "status": "completed", "description": "Card import feature with fuzzy matching, deck builder service, and API endpoints", - "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" - ] + "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"] }, { "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/.", "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": [ "alembic.ini", "alembic/env.py", @@ -93,7 +80,10 @@ "backend/mtg_rules_engine/update_check.py", "backend/mtg_rules_engine/test_engine.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": [ "app/models/__init__.py", @@ -134,6 +124,6 @@ "Update documentation and push to Gitea" ], "blockers": [], - "commit_hash": "165a6f1", - "timestamp": "2026-07-25T19:58:00-04:00" + "commit_hash": "1556a6d", + "timestamp": "2026-07-26T18:32:10-04:00" }