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.
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]
+1 -2
View File
@@ -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:
@@ -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: 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')
+2 -2
View File
@@ -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"
+1 -21
View File
@@ -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"<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.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
+1 -1
View File
@@ -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,
+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."""
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",
@@ -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
+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.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"
+1 -1
View File
@@ -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()
+7 -7
View File
@@ -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
+8 -8
View File
@@ -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,
},
+8 -18
View File
@@ -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"
}