diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 9546f8e..9a27db0 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -24,6 +24,7 @@ from app.models.user_data import ( UserGroup, GroupMember, GroupChatMessage, UserNetwork, NetworkMember, UserPreference, UserActivityLog ) +from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion # this is the Alembic Config object config = context.config diff --git a/backend/alembic/versions/002_user_deck_building_tables.py b/backend/alembic/versions/002_user_deck_building_tables.py new file mode 100644 index 0000000..b135a6c --- /dev/null +++ b/backend/alembic/versions/002_user_deck_building_tables.py @@ -0,0 +1,111 @@ +"""Add user deck building tables + +Revision ID: 002 +Revises: 001 +Create Date: 2026-01-02 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '002' +down_revision: Union[str, None] = '001' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create user deck building tables.""" + + # 1. User Decks Table + op.create_table( + 'user_decks', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), 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, index=True), + sa.Column('status', sa.String(20), nullable=False, default='DRAFT', index=True), + sa.Column('folder_id', sa.Integer(), sa.ForeignKey('mtgonline_decklist_folders.id'), nullable=True), + sa.Column('format', sa.String(50), nullable=True, default='standard'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('is_precedent', sa.Boolean(), default=False, index=True), + sa.Column('precedent_name', sa.String(255), 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. User Deck Cards Junction Table + op.create_table( + 'user_deck_cards', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True), + sa.Column('deck_id', sa.BigInteger(), sa.ForeignKey('user_decks.id', ondelete='CASCADE'), nullable=False, index=True), + sa.Column('card_id', sa.Integer(), nullable=False, index=True), + sa.Column('quantity', sa.Integer(), nullable=False, default=1), + sa.Column('zone', sa.String(20), nullable=False, default='main'), + sa.Column('position', sa.Integer(), nullable=True), + ) + op.create_unique_constraint( + 'uq_deck_card_unique', + 'user_deck_cards', + ['deck_id', 'card_id', 'zone'] + ) + op.create_index('idx_deck_cards_deck', 'user_deck_cards', ['deck_id']) + op.create_index('idx_deck_cards_card', 'user_deck_cards', ['card_id']) + + # 3. Deck Precedents Table + op.create_table( + 'deck_precedents', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True), + sa.Column('name', sa.String(255), nullable=False, index=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('format', sa.String(50), nullable=True, default='standard'), + sa.Column('is_public', sa.Boolean(), default=True, index=True), + sa.Column('created_by', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), 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()), + ) + + # 4. Deck Precedent Cards Junction Table + op.create_table( + 'deck_precedent_cards', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True), + sa.Column('precedent_id', sa.BigInteger(), sa.ForeignKey('deck_precedents.id', ondelete='CASCADE'), nullable=False, index=True), + sa.Column('card_id', sa.Integer(), nullable=False, index=True), + sa.Column('quantity', sa.Integer(), nullable=False, default=1), + sa.Column('zone', sa.String(20), nullable=False, default='main'), + ) + op.create_unique_constraint( + 'uq_precedent_card_unique', + 'deck_precedent_cards', + ['precedent_id', 'card_id', 'zone'] + ) + + # 5. Card Suggestions Table + op.create_table( + 'card_suggestions', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True), + sa.Column('deck_id', sa.BigInteger(), sa.ForeignKey('user_decks.id', ondelete='CASCADE'), nullable=False, index=True), + sa.Column('card_id', sa.Integer(), nullable=False, index=True), + sa.Column('source_card_id', sa.Integer(), nullable=True), + sa.Column('suggestion_type', sa.String(50), nullable=False, default='SIMILAR'), + sa.Column('confidence', sa.Float(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_unique_constraint( + 'uq_suggestion_unique', + 'card_suggestions', + ['deck_id', 'card_id', 'source_card_id'] + ) + + +def downgrade() -> None: + """Drop user deck building tables.""" + op.drop_table('card_suggestions') + op.drop_table('deck_precedent_cards') + op.drop_table('deck_precedents') + op.drop_table('user_deck_cards') + op.drop_table('user_decks') diff --git a/backend/alembic/versions/003_mtgonline_cards_table.py b/backend/alembic/versions/003_mtgonline_cards_table.py new file mode 100644 index 0000000..58bb147 --- /dev/null +++ b/backend/alembic/versions/003_mtgonline_cards_table.py @@ -0,0 +1,55 @@ +"""Add mtgonline_cards table for local card data mirror + +Revision ID: 003 +Revises: 002 +Create Date: 2026-07-23 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '003' +down_revision: Union[str, None] = '002' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create mtgonline_cards table for local card data mirror.""" + op.create_table( + 'mtgonline_cards', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('source_id', sa.Integer(), nullable=True, index=True), # References mtg_cards.id in mtgdata + sa.Column('name', sa.String(255), nullable=False, index=True), + sa.Column('mana_cost', sa.String(255), nullable=True), + sa.Column('type_line', sa.String(255), nullable=True), + sa.Column('oracle_text', sa.Text(), nullable=True), + sa.Column('power', sa.String(50), nullable=True), + sa.Column('toughness', sa.String(50), nullable=True), + sa.Column('rarity', sa.String(50), nullable=True), + sa.Column('layout', sa.String(50), nullable=True), + sa.Column('artist', sa.String(255), nullable=True), + sa.Column('flavor_text', sa.Text(), nullable=True), + sa.Column('numbers', sa.String(100), nullable=True), + sa.Column('identifiers', sa.Text(), nullable=True), # JSON string + sa.Column('images', sa.Text(), nullable=True), # JSON string + sa.Column('image', sa.Text(), nullable=True), # Card image URL + sa.Column('set_code', sa.String(10), nullable=True, index=True), + sa.Column('set_name', sa.String(255), nullable=True), + sa.Column('card_parts', sa.Text(), nullable=True), # Comma-separated face names + sa.Column('keywords', sa.Text(), nullable=True), # Comma-separated keywords + sa.Column('legalities', sa.Text(), nullable=True), # JSON of format legality + sa.Column('synced_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('idx_mtgonline_cards_name', 'mtgonline_cards', ['name']) + op.create_index('idx_mtgonline_cards_set', 'mtgonline_cards', ['set_code']) + + +def downgrade() -> None: + """Drop mtgonline_cards table.""" + op.drop_table('mtgonline_cards') diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c39ee25..5253e90 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,6 +8,7 @@ from app.models.user_data import ( UserGroup, GroupMember, GroupChatMessage, UserNetwork, NetworkMember, UserPreference, UserActivityLog ) +from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion __all__ = [ "User", @@ -37,4 +38,9 @@ __all__ = [ "NetworkMember", "UserPreference", "UserActivityLog", + "UserDeck", + "UserDeckCard", + "DeckPrecedent", + "DeckPrecedentCard", + "CardSuggestion", ] diff --git a/backend/app/models/mirror_models.py b/backend/app/models/mirror_models.py index 3a51284..ac69ab3 100644 --- a/backend/app/models/mirror_models.py +++ b/backend/app/models/mirror_models.py @@ -11,6 +11,7 @@ from sqlalchemy import ( from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.core.database import Base +from app.models.models import DecklistFile class MtgCardMirror(Base): diff --git a/backend/app/models/models.py b/backend/app/models/models.py index f0dcb2f..a84091e 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -45,6 +45,45 @@ class User(Base): return f"" +class MtgonlineCard(Base): + """ + Local card data mirror for the mtgonline database. + + Mirrors data from mtg_cards (mtgdata database) to avoid cross-database + joins during deckbuilding. Maintains source_id to reference the canonical + mtg_cards.id for sync tracking. + """ + __tablename__ = "mtgonline_cards" + + id = Column(Integer, primary_key=True, index=True) + source_id = Column(Integer, nullable=True, index=True) # References mtg_cards.id in mtgdata + name = Column(String(255), nullable=False, index=True) + mana_cost = Column(String(255), nullable=True) + type_line = Column(String(255), nullable=True) + oracle_text = Column(Text, nullable=True) + power = Column(String(50), nullable=True) + toughness = Column(String(50), nullable=True) + rarity = Column(String(50), nullable=True) + layout = Column(String(50), nullable=True) + artist = Column(String(255), nullable=True) + flavor_text = Column(Text, nullable=True) + numbers = Column(String(100), nullable=True) + identifiers = Column(Text, nullable=True) # JSON string + images = Column(Text, nullable=True) # JSON string + image = Column(Text, nullable=True) # Card image URL + set_code = Column(String(10), nullable=True, index=True) + set_name = Column(String(255), nullable=True) + card_parts = Column(Text, nullable=True) # Comma-separated face names + keywords = Column(Text, nullable=True) # Comma-separated keywords + legalities = Column(Text, nullable=True) # JSON of format legality + # Sync tracking + synced_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + created_at = Column(DateTime, server_default=func.now()) + + def __repr__(self) -> str: + return f"" + + class DecklistFolder(Base): """User deck folder.""" __tablename__ = "mtgonline_decklist_folders" diff --git a/backend/app/models/user_deck.py b/backend/app/models/user_deck.py new file mode 100644 index 0000000..e63a7ce --- /dev/null +++ b/backend/app/models/user_deck.py @@ -0,0 +1,153 @@ +""" +SQLAlchemy ORM models for per-user deck building. + +These models live in the primary mtgonline database and support +the deckbuilding feature with DRAFT/FINAL status, card management, +deck precedents, and card suggestions. +""" +from sqlalchemy import ( + Column, Integer, String, BigInteger, Boolean, DateTime, Text, + ForeignKey, UniqueConstraint, Index, Float +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base +from app.models.models import MtgonlineCard + + +class UserDeck(Base): + """Per-user deck storage in the primary mtgonline database.""" + __tablename__ = "user_decks" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True) + name = Column(String(255), nullable=False, index=True) + status = Column(String(20), nullable=False, default="DRAFT", index=True) # DRAFT or FINAL + folder_id = Column(Integer, ForeignKey("mtgonline_decklist_folders.id"), nullable=True) + format = Column(String(50), nullable=True, default="standard") # Standard, Modern, etc. + notes = Column(Text, nullable=True) + is_precedent = Column(Boolean, default=False, index=True) # Mark as deck precedent/template + precedent_name = Column(String(255), nullable=True) # Name for precedent (if applicable) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Relationships + user = relationship("User", backref="user_decks") + folder = relationship("DecklistFolder", backref="user_decks") + cards = relationship( + "UserDeckCard", + back_populates="deck", + cascade="all, delete-orphan", + order_by="UserDeckCard.id" + ) + + def __repr__(self) -> str: + return f"" + + +class UserDeckCard(Base): + """ + Junction table for user deck cards. + + Stores individual card entries in a user's deck with quantity and zone. + Card references point to mtg_cards.id in the mtgdata database. + """ + __tablename__ = "user_deck_cards" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + deck_id = Column(BigInteger, ForeignKey("user_decks.id", ondelete="CASCADE"), nullable=False, index=True) + card_id = Column(Integer, ForeignKey("mtgonline_cards.id"), nullable=False, index=True) + quantity = Column(Integer, nullable=False, default=1) + zone = Column(String(20), nullable=False, default="main") # 'main' or 'sideboard' + position = Column(Integer, nullable=True) # Optional ordering within zone + + __table_args__ = ( + UniqueConstraint('deck_id', 'card_id', 'zone', name='uq_deck_card_unique'), + Index('idx_deck_cards_deck', 'deck_id'), + Index('idx_deck_cards_card', 'card_id'), + ) + + # Relationships + deck = relationship("UserDeck", back_populates="cards") + card = relationship("MtgonlineCard", backref="deck_cards") + + def __repr__(self) -> str: + return f"" + + +class DeckPrecedent(Base): + """ + Deck precedent (template) storage. + + Precedents are pre-built deck templates that users can clone + to start building their own decks. + """ + __tablename__ = "deck_precedents" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False, index=True) + description = Column(Text, nullable=True) + format = Column(String(50), nullable=True, default="standard") + is_public = Column(Boolean, default=True, index=True) + created_by = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=True) # None = system + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Relationships + creator = relationship("User", foreign_keys=[created_by]) + cards = relationship( + "DeckPrecedentCard", + back_populates="precedent", + cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + +class DeckPrecedentCard(Base): + """Junction table for deck precedent cards.""" + __tablename__ = "deck_precedent_cards" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + precedent_id = Column(BigInteger, ForeignKey("deck_precedents.id", ondelete="CASCADE"), nullable=False, index=True) + card_id = Column(Integer, nullable=False, index=True) + quantity = Column(Integer, nullable=False, default=1) + zone = Column(String(20), nullable=False, default="main") + + __table_args__ = ( + UniqueConstraint('precedent_id', 'card_id', 'zone', name='uq_precedent_card_unique'), + ) + + precedent = relationship("DeckPrecedent", back_populates="cards") + + def __repr__(self) -> str: + return f"" + + +class CardSuggestion(Base): + """ + Card suggestion storage. + + Stores suggested cards for a deck based on similarity, + pairing patterns, or manual curation. + """ + __tablename__ = "card_suggestions" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + deck_id = Column(BigInteger, ForeignKey("user_decks.id", ondelete="CASCADE"), nullable=False, index=True) + card_id = Column(Integer, nullable=False, index=True) # Suggested card + source_card_id = Column(Integer, nullable=True) # Card that triggered the suggestion + suggestion_type = Column(String(50), nullable=False, default="SIMILAR") # SIMILAR, PAIRING, ALTERNATIVE + confidence = Column(Float, nullable=True) # 0.0 to 1.0 + notes = Column(Text, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + deck = relationship("UserDeck", backref="suggestions") + + __table_args__ = ( + UniqueConstraint('deck_id', 'card_id', 'source_card_id', name='uq_suggestion_unique'), + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/routers/decks.py b/backend/app/routers/decks.py index c6aacdb..b209690 100644 --- a/backend/app/routers/decks.py +++ b/backend/app/routers/decks.py @@ -1,76 +1,108 @@ -"""Deck management router endpoints.""" -from fastapi import APIRouter, Depends, HTTPException, status +""" +Deck management router endpoints for per-user deck building. + +Provides CRUD operations for user decks with card management, +deck finalization, precedent templates, and card search integration. +""" +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, delete, update, func +from sqlalchemy import select, delete, update, func, and_, or_ from typing import Optional, List -from app.core.database import get_db +from app.core.database import get_db, mtg_get_db from app.core.security import get_current_user -from app.models.models import DecklistFile, DecklistFolder -from app.models.mirror_models import DeckCardLink -from app.schemas.schemas import DeckCreate, DeckUpdate, DeckResponse, DeckWithCardsResponse, FolderCreate, FolderResponse +from app.models.models import User, DecklistFolder, MtgonlineCard +from app.models.mtg_models import MtgCard, MtgSet +from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion +from app.schemas.user_deck_schemas import ( + UserDeckCreate, UserDeckUpdate, UserDeckResponse, UserDeckListResponse, + DeckCardCreate, DeckCardUpdate, DeckCardResponse, DeckCardWithDetailsResponse, DeckCardListResponse, + PrecedentCreate, PrecedentUpdate, PrecedentResponse, PrecedentListResponse, + SuggestionCreate, SuggestionResponse, SuggestionListResponse, + DeckFinalizeRequest, DeckFinalizeResponse, + CardSearchRequest, CardSearchResponse, + MessageResponse, CountResponse +) router = APIRouter() -@router.get("/", response_model=List[DeckWithCardsResponse]) -async def list_decks( +# ===== Deck CRUD ===== + +@router.get("/", response_model=UserDeckListResponse) +async def list_user_decks( + status_filter: Optional[str] = None, folder_id: Optional[int] = None, - page: int = 1, - page_size: int = 50, + is_precedent: Optional[bool] = None, + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): - """List decks for current user.""" + """List user's decks with optional filtering.""" user_id = int(current_user["user_id"]) - + offset = (page - 1) * page_size + + # Build conditions + conditions = [UserDeck.user_id == user_id] + if status_filter: + conditions.append(UserDeck.status == status_filter) if folder_id: - stmt = ( - select(DecklistFile) - .where( - DecklistFile.owner_id == user_id, - DecklistFile.folder_id == folder_id, - ) - .offset((page - 1) * page_size) - .limit(page_size) - ) - else: - stmt = ( - select(DecklistFile) - .where(DecklistFile.owner_id == user_id) - .offset((page - 1) * page_size) - .limit(page_size) - ) - + conditions.append(UserDeck.folder_id == folder_id) + if is_precedent is not None: + conditions.append(UserDeck.is_precedent == is_precedent) + + # Count total + count_stmt = select(func.count()).select_from(UserDeck).where(*conditions) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # Fetch decks + stmt = select(UserDeck).where(*conditions).order_by(UserDeck.updated_at.desc()).offset(offset).limit(page_size) result = await db.execute(stmt) decks = result.scalars().all() - - # Include card counts for each deck - deck_responses = [] - for deck in decks: - # Get card count - count_stmt = select(func.count()).select_from(DeckCardLink).where( - DeckCardLink.deck_id == deck.id + + # Get card counts + deck_ids = [d.id for d in decks] + card_counts = {} + if deck_ids: + count_subquery = ( + select(UserDeckCard.deck_id, func.count().label('cnt')) + .where(UserDeckCard.deck_id.in_(deck_ids)) + .group_by(UserDeckCard.deck_id) + .subquery() + ) + count_stmt = select(count_subquery.c.deck_id, count_subquery.c.cnt).where( + count_subquery.c.deck_id.in_(deck_ids) ) count_result = await db.execute(count_stmt) - card_count = count_result.scalar() or 0 - - deck_data = DeckWithCardsResponse.model_validate(deck) - deck_data.card_count = card_count + card_counts = {row[0]: row[1] for row in count_result.fetchall()} + + deck_responses = [] + for deck in decks: + deck_data = UserDeckResponse.model_validate(deck) + deck_data.card_count = card_counts.get(deck.id, 0) + deck_data.is_owner = True deck_responses.append(deck_data) - - return deck_responses + + return UserDeckListResponse( + decks=deck_responses, + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size, + ) -@router.post("/", response_model=DeckWithCardsResponse) -async def create_deck( - request: DeckCreate, +@router.post("/", response_model=UserDeckResponse, status_code=status.HTTP_201_CREATED) +async def create_user_deck( + request: UserDeckCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): - """Create a new deck.""" + """Create a new user deck (DRAFT status).""" user_id = int(current_user["user_id"]) - + # Verify folder exists if specified if request.folder_id: stmt = select(DecklistFolder).where( @@ -80,223 +112,670 @@ async def create_deck( result = await db.execute(stmt) folder = result.scalar_one_or_none() if not folder: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Folder not found", - ) - - new_deck = DecklistFile( - owner_id=user_id, - folder_id=request.folder_id, + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found") + + new_deck = UserDeck( + user_id=user_id, name=request.name, - content=request.content, + folder_id=request.folder_id, format=request.format, - status=request.status, + notes=request.notes, + is_precedent=request.is_precedent, + precedent_name=request.precedent_name, ) db.add(new_deck) await db.flush() - - # Return with card count (0 for new deck) - result = DeckWithCardsResponse.model_validate(new_deck) - result.card_count = 0 - return result + + deck_data = UserDeckResponse.model_validate(new_deck) + deck_data.card_count = 0 + deck_data.is_owner = True + return deck_data -@router.get("/folders", response_model=List[FolderResponse]) -async def list_folders( - parent_id: Optional[int] = None, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user), -): - """List folders for current user.""" - user_id = int(current_user["user_id"]) - - if parent_id: - stmt = select(DecklistFolder).where( - DecklistFolder.parent_id == parent_id, - DecklistFolder.owner_id == user_id, - ) - else: - stmt = select(DecklistFolder).where( - DecklistFolder.parent_id == None, # Top-level folders - DecklistFolder.owner_id == user_id, - ) - - result = await db.execute(stmt) - folders = result.scalars().all() - - return [FolderResponse.model_validate(folder) for folder in folders] - - -@router.post("/folders", response_model=FolderResponse) -async def create_folder( - request: FolderCreate, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user), -): - """Create a new folder.""" - user_id = int(current_user["user_id"]) - - # Verify parent folder exists if specified - if request.parent_id: - stmt = select(DecklistFolder).where( - DecklistFolder.id == request.parent_id, - DecklistFolder.owner_id == user_id, - ) - result = await db.execute(stmt) - parent_folder = result.scalar_one_or_none() - if not parent_folder: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Parent folder not found", - ) - - new_folder = DecklistFolder( - owner_id=user_id, - name=request.name, - parent_id=request.parent_id, - ) - db.add(new_folder) - await db.flush() - - return FolderResponse.model_validate(new_folder) - - -@router.delete("/folders/{folder_id}") -async def delete_folder( - folder_id: int, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user), -): - """Delete folder and all its contents.""" - user_id = int(current_user["user_id"]) - - stmt = select(DecklistFolder).where( - DecklistFolder.id == folder_id, - DecklistFolder.owner_id == user_id, - ) - result = await db.execute(stmt) - folder = result.scalar_one_or_none() - - if not folder: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Folder not found", - ) - - # Delete folder and all decks (cascading delete) - await db.execute(delete(DecklistFolder).where(DecklistFolder.id == folder_id)) - - return {"message": "Folder deleted successfully"} - - -@router.get("/{deck_id}", response_model=DeckWithCardsResponse) -async def get_deck( +@router.get("/{deck_id}", response_model=UserDeckResponse) +async def get_user_deck( deck_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): - """Get deck by ID.""" + """Get a specific user deck.""" user_id = int(current_user["user_id"]) - - stmt = select(DecklistFile).where( - DecklistFile.id == deck_id, - DecklistFile.owner_id == user_id, + + stmt = select(UserDeck).where( + UserDeck.id == deck_id, + UserDeck.user_id == user_id, ) result = await db.execute(stmt) deck = result.scalar_one_or_none() - + if not deck: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Deck not found", - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + # Get card count - count_stmt = select(func.count()).select_from(DeckCardLink).where( - DeckCardLink.deck_id == deck_id - ) + count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id) count_result = await db.execute(count_stmt) card_count = count_result.scalar() or 0 - - result = DeckWithCardsResponse.model_validate(deck) - result.card_count = card_count - return result + + deck_data = UserDeckResponse.model_validate(deck) + deck_data.card_count = card_count + deck_data.is_owner = True + return deck_data -@router.patch("/{deck_id}", response_model=DeckWithCardsResponse) -async def update_deck( +@router.patch("/{deck_id}", response_model=UserDeckResponse) +async def update_user_deck( deck_id: int, - request: DeckUpdate, + request: UserDeckUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): - """Update deck.""" + """Update a user deck.""" user_id = int(current_user["user_id"]) - - stmt = select(DecklistFile).where( - DecklistFile.id == deck_id, - DecklistFile.owner_id == user_id, - ) + + stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) result = await db.execute(stmt) deck = result.scalar_one_or_none() - + if not deck: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Deck not found", - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + + # Can't modify FINAL decks + if deck.status == "FINAL": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck") + # Update fields update_data = request.model_dump(exclude_unset=True) - - stmt = ( - update(DecklistFile) - .where(DecklistFile.id == deck_id) - .values(**update_data) - ) + if "status" in update_data and update_data["status"]: + update_data["status"] = update_data["status"].value + + stmt = update(UserDeck).where(UserDeck.id == deck_id).values(**update_data) await db.execute(stmt) await db.flush() - + # Fetch updated deck - stmt = select(DecklistFile).where(DecklistFile.id == deck_id) + stmt = select(UserDeck).where(UserDeck.id == deck_id) result = await db.execute(stmt) updated_deck = result.scalar_one_or_none() - + # Get card count - count_stmt = select(func.count()).select_from(DeckCardLink).where( - DeckCardLink.deck_id == deck_id - ) + count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id) count_result = await db.execute(count_stmt) card_count = count_result.scalar() or 0 - - result = DeckWithCardsResponse.model_validate(updated_deck) - result.card_count = card_count - return result + + deck_data = UserDeckResponse.model_validate(updated_deck) + deck_data.card_count = card_count + deck_data.is_owner = True + return deck_data -@router.delete("/{deck_id}") -async def delete_deck( +@router.delete("/{deck_id}", response_model=MessageResponse) +async def delete_user_deck( deck_id: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): - """Delete deck.""" + """Delete a user deck.""" user_id = int(current_user["user_id"]) - - stmt = select(DecklistFile).where( - DecklistFile.id == deck_id, - DecklistFile.owner_id == user_id, - ) + + stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) result = await db.execute(stmt) deck = result.scalar_one_or_none() - + if not deck: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Deck not found", + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + + await db.execute(delete(UserDeck).where(UserDeck.id == deck_id)) + await db.flush() + + return MessageResponse(message="Deck deleted successfully") + + +# ===== Deck Finalize ===== + +@router.post("/{deck_id}/finalize", response_model=DeckFinalizeResponse) +async def finalize_user_deck( + deck_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Transition a deck from DRAFT to FINAL status.""" + user_id = int(current_user["user_id"]) + + stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) + result = await db.execute(stmt) + deck = result.scalar_one_or_none() + + if not deck: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + + if deck.status == "FINAL": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Deck is already finalized") + + # Check deck has cards + count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id) + count_result = await db.execute(count_stmt) + card_count = count_result.scalar() or 0 + if card_count == 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot finalize an empty deck") + + # Update status + stmt = update(UserDeck).where(UserDeck.id == deck_id).values(status="FINAL") + await db.execute(stmt) + await db.flush() + + return DeckFinalizeResponse( + deck_id=deck_id, + status="FINAL", + message="Deck finalized successfully", + ) + + +# ===== Deck Card Management ===== + +@router.post("/{deck_id}/cards", response_model=DeckCardResponse, status_code=status.HTTP_201_CREATED) +async def add_deck_card( + deck_id: int, + request: DeckCardCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Add a card to a user's deck.""" + user_id = int(current_user["user_id"]) + + # Verify deck exists and belongs to user + stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) + result = await db.execute(stmt) + deck = result.scalar_one_or_none() + + if not deck: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + + # Can't modify FINAL decks + if deck.status == "FINAL": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck") + + # Verify card exists in local mirror + card_stmt = select(MtgonlineCard).where(MtgonlineCard.id == request.card_id) + card_result = await db.execute(card_stmt) + card = card_result.scalar_one_or_none() + if not card: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in database") + + # Check for duplicate (same card, same zone) + existing_stmt = select(UserDeckCard).where( + UserDeckCard.deck_id == deck_id, + UserDeckCard.card_id == request.card_id, + UserDeckCard.zone == request.zone.value, + ) + existing_result = await db.execute(existing_stmt) + existing = existing_result.scalar_one_or_none() + + if existing: + # Update quantity + new_qty = existing.quantity + request.quantity + stmt = update(UserDeckCard).where(UserDeckCard.id == existing.id).values(quantity=new_qty) + await db.execute(stmt) + await db.flush() + + stmt = select(UserDeckCard).where(UserDeckCard.id == existing.id) + result = await db.execute(stmt) + updated = result.scalar_one_or_none() + else: + new_card = UserDeckCard( + deck_id=deck_id, + card_id=request.card_id, + quantity=request.quantity, + zone=request.zone.value, + position=request.position, ) - - await db.execute(delete(DecklistFile).where(DecklistFile.id == deck_id)) - - return {"message": "Deck deleted successfully"} + db.add(new_card) + await db.flush() + updated = new_card + + return DeckCardResponse.model_validate(updated) + + +@router.get("/{deck_id}/cards", response_model=DeckCardListResponse) +async def get_deck_cards( + deck_id: int, + zone: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get all cards in a user's deck.""" + user_id = int(current_user["user_id"]) + + # Verify deck exists and belongs to user + stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) + result = await db.execute(stmt) + deck = result.scalar_one_or_none() + + if not deck: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + + # Build conditions + conditions = [UserDeckCard.deck_id == deck_id] + if zone: + conditions.append(UserDeckCard.zone == zone) + + # Fetch cards + stmt = select(UserDeckCard).where(*conditions).order_by(UserDeckCard.id) + result = await db.execute(stmt) + deck_cards = result.scalars().all() + + # Fetch card details from local mirror + card_ids = [dc.card_id for dc in deck_cards] + card_details = {} + if card_ids: + card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids)) + card_result = await db.execute(card_stmt) + for c in card_result.scalars().all(): + card_details[c.id] = c + + card_responses = [] + for dc in deck_cards: + card = card_details.get(dc.card_id) + response = DeckCardWithDetailsResponse.model_validate(dc) + response.card_name = card.name if card else f"Card#{dc.card_id}" + response.card_type_line = card.type_line if card else "" + response.card_image = card.image if card else None + card_responses.append(response) + + return DeckCardListResponse(cards=card_responses, total=len(card_responses)) + + +@router.patch("/{deck_id}/cards/{card_id}", response_model=DeckCardResponse) +async def update_deck_card( + deck_id: int, + card_id: int, + request: DeckCardUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Update a card's quantity or zone in a deck.""" + user_id = int(current_user["user_id"]) + + # Verify deck exists and belongs to user + stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) + result = await db.execute(stmt) + deck = result.scalar_one_or_none() + + if not deck: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + + if deck.status == "FINAL": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck") + + # Find the card entry + stmt = select(UserDeckCard).where( + UserDeckCard.deck_id == deck_id, + UserDeckCard.card_id == card_id, + ) + result = await db.execute(stmt) + deck_card = result.scalar_one_or_none() + + if not deck_card: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in deck") + + update_data = request.model_dump(exclude_unset=True) + if "zone" in update_data and update_data["zone"]: + update_data["zone"] = update_data["zone"].value + + stmt = update(UserDeckCard).where(UserDeckCard.id == deck_card.id).values(**update_data) + await db.execute(stmt) + await db.flush() + + stmt = select(UserDeckCard).where(UserDeckCard.id == deck_card.id) + result = await db.execute(stmt) + updated = result.scalar_one_or_none() + + return DeckCardResponse.model_validate(updated) + + +@router.delete("/{deck_id}/cards/{card_id}", response_model=MessageResponse) +async def remove_deck_card( + deck_id: int, + card_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Remove a card from a user's deck.""" + user_id = int(current_user["user_id"]) + + # Verify deck exists and belongs to user + stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) + result = await db.execute(stmt) + deck = result.scalar_one_or_none() + + if not deck: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + + if deck.status == "FINAL": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck") + + # Find the card entry + stmt = select(UserDeckCard).where( + UserDeckCard.deck_id == deck_id, + UserDeckCard.card_id == card_id, + ) + result = await db.execute(stmt) + deck_card = result.scalar_one_or_none() + + if not deck_card: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in deck") + + await db.execute(delete(UserDeckCard).where(UserDeckCard.id == deck_card.id)) + await db.flush() + + return MessageResponse(message="Card removed from deck") + + +# ===== Deck Precedents ===== + +@router.get("/precedents", response_model=PrecedentListResponse) +async def list_precedents( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + format_filter: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """List available deck precedents.""" + offset = (page - 1) * page_size + + conditions = [DeckPrecedent.is_public == True] + if format_filter: + conditions.append(DeckPrecedent.format == format_filter) + + # Count total + count_stmt = select(func.count()).select_from(DeckPrecedent).where(*conditions) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # Fetch precedents + stmt = select(DeckPrecedent).where(*conditions).order_by(DeckPrecedent.created_at.desc()).offset(offset).limit(page_size) + result = await db.execute(stmt) + precedents = result.scalars().all() + + # Get card counts + prec_ids = [p.id for p in precedents] + card_counts = {} + if prec_ids: + count_subquery = ( + select(DeckPrecedentCard.precedent_id, func.count().label('cnt')) + .where(DeckPrecedentCard.precedent_id.in_(prec_ids)) + .group_by(DeckPrecedentCard.precedent_id) + .subquery() + ) + count_stmt = select(count_subquery.c.precedent_id, count_subquery.c.cnt).where( + count_subquery.c.precedent_id.in_(prec_ids) + ) + count_result = await db.execute(count_stmt) + card_counts = {row[0]: row[1] for row in count_result.fetchall()} + + prec_responses = [] + for p in precedents: + prec_data = PrecedentResponse.model_validate(p) + prec_data.card_count = card_counts.get(p.id, 0) + prec_responses.append(prec_data) + + return PrecedentListResponse( + precedents=prec_responses, + total=total, + page=page, + page_size=page_size, + total_pages=(total + page_size - 1) // page_size, + ) + + +@router.post("/precedents", response_model=PrecedentResponse, status_code=status.HTTP_201_CREATED) +async def create_precedent( + request: PrecedentCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Create a deck precedent (template).""" + user_id = int(current_user["user_id"]) + + new_precedent = DeckPrecedent( + name=request.name, + description=request.description, + format=request.format, + is_public=request.is_public, + created_by=user_id, + ) + db.add(new_precedent) + await db.flush() + + prec_data = PrecedentResponse.model_validate(new_precedent) + prec_data.card_count = 0 + return prec_data + + +@router.get("/precedents/{precedent_id}", response_model=PrecedentResponse) +async def get_precedent( + precedent_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get a specific deck precedent.""" + stmt = select(DeckPrecedent).where(DeckPrecedent.id == precedent_id) + result = await db.execute(stmt) + precedent = result.scalar_one_or_none() + + if not precedent: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Precedent not found") + + # Get card count + count_stmt = select(func.count()).select_from(DeckPrecedentCard).where(DeckPrecedentCard.precedent_id == precedent_id) + count_result = await db.execute(count_stmt) + card_count = count_result.scalar() or 0 + + prec_data = PrecedentResponse.model_validate(precedent) + prec_data.card_count = card_count + return prec_data + + +@router.post("/precedents/{precedent_id}/use") +async def use_precedent( + precedent_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Clone a precedent into a new draft deck for the current user.""" + user_id = int(current_user["user_id"]) + + # Get precedent + stmt = select(DeckPrecedent).where(DeckPrecedent.id == precedent_id) + result = await db.execute(stmt) + precedent = result.scalar_one_or_none() + + if not precedent: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Precedent not found") + + # Create new deck from precedent + new_deck = UserDeck( + user_id=user_id, + name=f"Copy of {precedent.name}", + format=precedent.format, + is_precedent=False, + ) + db.add(new_deck) + await db.flush() + + # Copy cards from precedent + card_stmt = select(DeckPrecedentCard).where(DeckPrecedentCard.precedent_id == precedent_id) + card_result = await db.execute(card_stmt) + precedent_cards = card_result.scalars().all() + + for pc in precedent_cards: + new_dc = UserDeckCard( + deck_id=new_deck.id, + card_id=pc.card_id, + quantity=pc.quantity, + zone=pc.zone, + ) + db.add(new_dc) + + await db.flush() + + return { + "message": "Precedent cloned into new deck", + "deck_id": new_deck.id, + "deck_name": new_deck.name, + "card_count": len(precedent_cards), + } + + +# ===== Card Search Integration ===== + +@router.post("/search/cards", response_model=CardSearchResponse) +async def search_cards_for_deck( + request: CardSearchRequest, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Search MTG cards for use in deck building.""" + offset = (request.offset // request.limit) * request.limit + + # Search across multiple fields using local mirror + stmt = ( + select(MtgonlineCard) + .where( + or_( + MtgonlineCard.name.ilike(f"%{request.query}%"), + MtgonlineCard.type_line.ilike(f"%{request.query}%"), + MtgonlineCard.mana_cost.ilike(f"%{request.query}%"), + ) + ) + .offset(offset) + .limit(request.limit) + ) + + result = await db.execute(stmt) + cards = result.scalars().all() + + card_list = [] + for card in cards: + card_data = { + "id": card.id, + "name": card.name, + "mana_cost": card.mana_cost, + "type_line": card.type_line, + "oracle_text": card.oracle_text, + "power": card.power, + "toughness": card.toughness, + "rarity": card.rarity, + "layout": card.layout, + "artist": card.artist, + "flavor_text": card.flavor_text, + "set_code": card.set_code, + "set_name": card.set_name, + "identifiers": card.identifiers, + "images": card.images, + } + card_list.append(card_data) + + # Get total count + count_stmt = select(func.count()).select_from(MtgonlineCard).where( + or_( + MtgonlineCard.name.ilike(f"%{request.query}%"), + MtgonlineCard.type_line.ilike(f"%{request.query}%"), + MtgonlineCard.mana_cost.ilike(f"%{request.query}%"), + ) + ) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + return CardSearchResponse( + cards=card_list, + total=total, + page=request.offset // request.limit + 1, + page_size=request.limit, + total_pages=(total + request.limit - 1) // request.limit, + ) + + +# ===== Card Suggestions ===== + +@router.get("/{deck_id}/suggestions", response_model=SuggestionListResponse) +async def get_deck_suggestions( + deck_id: int, + suggestion_type: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Get card suggestions for a deck.""" + user_id = int(current_user["user_id"]) + + # Verify deck exists and belongs to user + stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) + result = await db.execute(stmt) + deck = result.scalar_one_or_none() + + if not deck: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + + conditions = [CardSuggestion.deck_id == deck_id] + if suggestion_type: + conditions.append(CardSuggestion.suggestion_type == suggestion_type) + + # Fetch suggestions + stmt = select(CardSuggestion).where(*conditions).order_by(CardSuggestion.created_at.desc()) + result = await db.execute(stmt) + suggestions = result.scalars().all() + + # Fetch card names from local mirror + card_ids = [s.card_id for s in suggestions] + card_names = {} + if card_ids: + card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids)) + card_result = await db.execute(card_stmt) + for c in card_result.scalars().all(): + card_names[c.id] = c.name + + sugg_responses = [] + for s in suggestions: + sugg_data = SuggestionResponse.model_validate(s) + sugg_data.card_name = card_names.get(s.card_id, f"Card#{s.card_id}") + sugg_responses.append(sugg_data) + + return SuggestionListResponse( + suggestions=sugg_responses, + total=len(sugg_responses), + ) + + +@router.post("/{deck_id}/suggestions", response_model=SuggestionResponse, status_code=status.HTTP_201_CREATED) +async def add_suggestion( + deck_id: int, + request: SuggestionCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Add a card suggestion to a deck.""" + user_id = int(current_user["user_id"]) + + # Verify deck exists and belongs to user + stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) + result = await db.execute(stmt) + deck = result.scalar_one_or_none() + + if not deck: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") + + if deck.status == "FINAL": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck") + + new_suggestion = CardSuggestion( + deck_id=deck_id, + card_id=request.card_id, + source_card_id=request.source_card_id, + suggestion_type=request.suggestion_type.value, + confidence=request.confidence, + notes=request.notes, + ) + db.add(new_suggestion) + await db.flush() + + return SuggestionResponse.model_validate(new_suggestion) diff --git a/backend/app/schemas/user_deck_schemas.py b/backend/app/schemas/user_deck_schemas.py new file mode 100644 index 0000000..0499f39 --- /dev/null +++ b/backend/app/schemas/user_deck_schemas.py @@ -0,0 +1,242 @@ +"""Pydantic schemas for user deck building features.""" +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime +from enum import Enum + + +# ===== Enum Types ===== + +class DeckStatus(str, Enum): + DRAFT = "DRAFT" + FINAL = "FINAL" + + +class DeckZone(str, Enum): + MAIN = "main" + SIDEBOARD = "sideboard" + + +class SuggestionType(str, Enum): + SIMILAR = "SIMILAR" + PAIRING = "PAIRING" + ALTERNATIVE = "ALTERNATIVE" + + +# ===== Deck Schemas ===== + +class UserDeckCreate(BaseModel): + """Deck creation request.""" + name: str = Field(..., min_length=1, max_length=255) + folder_id: Optional[int] = None + format: Optional[str] = Field("standard", max_length=50) + notes: Optional[str] = None + is_precedent: bool = False + precedent_name: Optional[str] = None + + +class UserDeckUpdate(BaseModel): + """Deck update request.""" + name: Optional[str] = None + folder_id: Optional[int] = None + format: Optional[str] = None + notes: Optional[str] = None + status: Optional[DeckStatus] = None + is_precedent: Optional[bool] = None + precedent_name: Optional[str] = None + + +class UserDeckResponse(BaseModel): + """Deck response with card count.""" + id: int + user_id: int + name: str + status: str + folder_id: Optional[int] + format: Optional[str] + notes: Optional[str] + is_precedent: bool + precedent_name: Optional[str] + created_at: datetime + updated_at: datetime + card_count: int = 0 + is_owner: bool = False + + class Config: + from_attributes = True + + +class UserDeckListResponse(BaseModel): + """List of user decks.""" + decks: List[UserDeckResponse] + total: int + page: int + page_size: int + total_pages: int + + +# ===== Deck Card Schemas ===== + +class DeckCardCreate(BaseModel): + """Add card to deck.""" + card_id: int + quantity: int = Field(1, ge=1) + zone: DeckZone = DeckZone.MAIN + position: Optional[int] = None + + +class DeckCardUpdate(BaseModel): + """Update card in deck.""" + quantity: Optional[int] = None + zone: Optional[DeckZone] = None + position: Optional[int] = None + + +class DeckCardResponse(BaseModel): + """Deck card response.""" + id: int + deck_id: int + card_id: int + quantity: int + zone: str + position: Optional[int] + created_at: datetime + + class Config: + from_attributes = True + + +class DeckCardWithDetailsResponse(DeckCardResponse): + """Deck card with card details.""" + card_name: str = "" + card_type_line: str = "" + card_image: Optional[str] = None + + +class DeckCardListResponse(BaseModel): + """List of deck cards.""" + cards: List[DeckCardWithDetailsResponse] + total: int + + +# ===== Deck Precedent Schemas ===== + +class PrecedentCreate(BaseModel): + """Create deck precedent.""" + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + format: Optional[str] = Field("standard", max_length=50) + is_public: bool = True + + +class PrecedentUpdate(BaseModel): + """Update deck precedent.""" + name: Optional[str] = None + description: Optional[str] = None + format: Optional[str] = None + is_public: Optional[bool] = None + + +class PrecedentResponse(BaseModel): + """Deck precedent response.""" + id: int + name: str + description: Optional[str] + format: Optional[str] + is_public: bool + created_by: Optional[int] + created_at: datetime + updated_at: datetime + card_count: int = 0 + + class Config: + from_attributes = True + + +class PrecedentListResponse(BaseModel): + """List of deck precedents.""" + precedents: List[PrecedentResponse] + total: int + + +# ===== Card Suggestion Schemas ===== + +class SuggestionCreate(BaseModel): + """Create card suggestion.""" + card_id: int + source_card_id: Optional[int] = None + suggestion_type: SuggestionType = SuggestionType.SIMILAR + confidence: Optional[float] = Field(None, ge=0.0, le=1.0) + notes: Optional[str] = None + + +class SuggestionResponse(BaseModel): + """Card suggestion response.""" + id: int + deck_id: int + card_id: int + source_card_id: Optional[int] + suggestion_type: str + confidence: Optional[float] + notes: Optional[str] + created_at: datetime + card_name: str = "" + + class Config: + from_attributes = True + + +class SuggestionListResponse(BaseModel): + """List of card suggestions.""" + suggestions: List[SuggestionResponse] + total: int + + +# ===== Deck Action Schemas ===== + +class DeckFinalizeRequest(BaseModel): + """Request to finalize a deck.""" + status: DeckStatus = DeckStatus.FINAL + + +class DeckFinalizeResponse(BaseModel): + """Response after finalizing a deck.""" + deck_id: int + status: str + message: str + + +class DeckDeleteResponse(BaseModel): + """Response after deleting a deck.""" + deck_id: int + message: str + + +# ===== Search Schemas ===== + +class CardSearchRequest(BaseModel): + """Card search request.""" + query: str = Field(..., min_length=1, max_length=100) + limit: int = Field(50, ge=1, le=200) + offset: int = Field(0, ge=0) + + +class CardSearchResponse(BaseModel): + """Card search response.""" + cards: List[Dict[str, Any]] + total: int + page: int + page_size: int + total_pages: int + + +# ===== Generic Schemas ===== + +class MessageResponse(BaseModel): + """Generic message response.""" + message: str + + +class CountResponse(BaseModel): + """Generic count response.""" + count: int diff --git a/state.json b/state.json index 77c5f3e..40df76e 100644 --- a/state.json +++ b/state.json @@ -21,9 +21,33 @@ }, { "phase": 4, + "status": "completed", + "description": "Per-user deck building with card search, precedents, and suggestions", + "key_deliverables": [ + "UserDeck model (DRAFT/FINAL status)", + "UserDeckCard junction table (card_id references mtgonline_cards.id)", + "DeckPrecedent and DeckPrecedentCard tables (template support)", + "CardSuggestion table (suggestion storage)", + "Alembic migration 003 (mtgonline_cards table)", + "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": 5, "status": "pending", - "description": "Testing and deployment", - "key_deliverables": ["Migration tests", "API tests", "Docker deployment", "Integration tests"] + "description": "Card import with fuzzy matching and deck builder service", + "key_deliverables": [ + "Fuzzy card matching service (python-Levenshtein)", + "Card import router (file upload/confirm)", + "Deck builder service (precedents, suggestions)", + "Integration tests for import and builder features" + ] } ], "tech_stack": { @@ -46,17 +70,23 @@ }, "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.", "task_description": "Update HANDOFF.md and ROADMAP.md to reflect user data schema work and consolidate state.json", - "current_step": "Phase 3 completed - All API endpoints created with comprehensive documentation", + "current_step": "Phase 4 completed - Per-user deck building with card search, precedents, and suggestions fully implemented", "files_created": [ "alembic.ini", "alembic/env.py", "alembic/versions/001_initial_user_schema.py", + "alembic/versions/002_user_deck_building_tables.py", + "alembic/versions/003_mtgonline_cards_table.py", "alembic/versions/__init__.py", "app/models/user_data.py", + "app/models/user_deck.py", + "app/models/models.py (MtgonlineCard added)", + "app/schemas/user_data_schemas.py", + "app/schemas/user_deck_schemas.py", + "app/routers/user_data.py", + "app/routers/decks.py", "scripts/run_migrations.sh", "TEST_PLAN.md", - "app/schemas/user_data_schemas.py", - "app/routers/user_data.py", "API_DOCUMENTATION.md" ], "files_modified": [ @@ -72,16 +102,21 @@ "Composite unique constraints for card collection uniqueness", "RESTful API design with pagination support", "JWT authentication for all endpoints", - "Permission checks for group/network management" + "Permission checks for group/network management", + "Option B for cross-DB FK: Local card mirror in mtgonline DB (mtgonline_cards)", + "Fuzzy matching reserved for card import feature only (handles typos in user input)", + "Local card search endpoint for fast deckbuilding queries" ], "next_steps": [ "Test migration execution in container", "Run API tests against all endpoints", "Add rate limiting for production", "Create integration tests", - "Deploy to staging environment" + "Deploy to staging environment", + "Phase 5: Card import with fuzzy matching (python-Levenshtein)", + "Phase 5: Deck builder service for precedents and suggestions" ], "blockers": [], "commit_hash": "6b38ef0", - "timestamp": "2026-07-23T00:30:06-04:00" + "timestamp": "2026-07-24T04:12:00-04:00" }