""" Deck Management Router — Complete Deck Building Functionality. This router provides the full deck building workflow for MTG Online users, including: * CRUD operations for per-user decklists (draft, final, precedent). * Card management within decks (add, update, remove, list). * Deck finalization (transition from DRAFT to FINAL status). * Precedent (template) management — list, create, and clone into new decks. * Card search integration using the local card mirror. * Card suggestion system for deck refinement. All endpoints require authentication via ``get_current_user`` and enforce per-user ownership checks where appropriate. Routes ------ Deck CRUD:: GET / List user decks (paginated, filterable) POST / Create a new draft deck GET /{deck_id} Get a specific deck PATCH /{deck_id} Update deck metadata DELETE /{deck_id} Delete a deck Deck Finalize:: POST /{deck_id}/finalize Transition deck to FINAL status Deck Card Management:: POST /{deck_id}/cards Add a card to a deck GET /{deck_id}/cards List cards in a deck PATCH /{deck_id}/cards/{card_id} Update card quantity/zone DELETE /{deck_id}/cards/{card_id} Remove a card from a deck Precedents:: GET /precedents List public precedents POST /precedents Create a precedent GET /precedents/{id} Get a specific precedent POST /precedents/{id}/use Clone precedent into a new draft deck Card Search:: POST /search/cards Search the local card mirror Suggestions:: GET /{deck_id}/suggestions List suggestions for a deck POST /{deck_id}/suggestions Add a suggestion to a deck """ from __future__ import annotations from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.security import get_current_user from app.models.mtg_models import MtgCard, MtgSet from app.models.models import MtgonlineCard, User, DecklistFolder from app.models.models import ( CardSuggestion, DeckPrecedent, DeckPrecedentCard, UserDeck, UserDeckCard, ) from app.schemas.card_search_schemas import CardSearchResponse from app.schemas.generic_schemas import CountResponse, MessageResponse from app.schemas.user_deck_schemas import ( CardSearchRequest, DeckCardCreate, DeckCardResponse, DeckCardUpdate, DeckCardListResponse, DeckCardWithDetailsResponse, DeckFinalizeRequest, DeckFinalizeResponse, PrecedentCreate, PrecedentResponse, PrecedentListResponse, PrecedentUpdate, SuggestionCreate, SuggestionListResponse, SuggestionResponse, UserDeckCreate, UserDeckResponse, UserDeckUpdate, UserDeckListResponse, ) from app.services.deck_manager import DeckManager from app.services.deck_parser import DeckParser router = APIRouter() _deck_mgr: DeckManager = DeckManager() _deck_parser: DeckParser = DeckParser() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _get_user_id(current_user: dict) -> int: """Extract and convert the authenticated user's ID.""" return int(current_user["user_id"]) async def _verify_deck_ownership( db: AsyncSession, deck_id: int, user_id: int, ) -> UserDeck: """Verify that a deck exists and belongs to the user. Raises: HTTPException: 404 if the deck is not found. """ 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") return deck async def _get_card_count(db: AsyncSession, deck_id: int) -> int: """Return the number of cards in a deck.""" count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id) count_result = await db.execute(count_stmt) return count_result.scalar() or 0 async def _enrich_deck_response( deck: UserDeck, card_count: int, ) -> UserDeckResponse: """Build a ``UserDeckResponse`` with card count and ownership flag.""" deck_data = UserDeckResponse.model_validate(deck) deck_data.card_count = card_count deck_data.is_owner = True return deck_data # --------------------------------------------------------------------------- # Deck CRUD # --------------------------------------------------------------------------- @router.get("/", response_model=UserDeckListResponse) async def list_user_decks( status_filter: Optional[str] = None, folder_id: Optional[int] = None, 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), ) -> UserDeckListResponse: """List user's decks with optional filtering. Supports filtering by deck status, folder, and whether the deck is a precedent. Results are paginated. """ user_id = _get_user_id(current_user) try: decks = await _deck_mgr.list_decks( db=db, user_id=user_id, status_filter=status_filter, folder_id=folder_id, is_precedent=is_precedent, page=page, page_size=page_size, ) # Build response with card counts deck_ids = [d.id for d in decks] card_counts: dict[int, int] = {} 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_counts = {row[0]: row[1] for row in count_result.fetchall()} deck_responses: list[UserDeckResponse] = [] 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) total = len(decks) return UserDeckListResponse( decks=deck_responses, total=total, page=page, page_size=page_size, total_pages=(total + page_size - 1) // page_size, ) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @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), ) -> UserDeckResponse: """Create a new user deck in DRAFT status.""" user_id = _get_user_id(current_user) try: new_deck = await _deck_mgr.create_deck( db=db, user_id=user_id, name=request.name, folder_id=request.folder_id, format=request.format, notes=request.notes, is_precedent=request.is_precedent, precedent_name=request.precedent_name, ) deck_data = UserDeckResponse.model_validate(new_deck) deck_data.card_count = 0 deck_data.is_owner = True return deck_data except HTTPException: raise except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) @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), ) -> UserDeckResponse: """Get a specific user deck by ID.""" user_id = _get_user_id(current_user) try: deck = await _deck_mgr.get_deck(db=db, deck_id=deck_id, user_id=user_id) if not deck: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") card_count = await _get_card_count(db, deck_id) return await _enrich_deck_response(deck, card_count) except HTTPException: raise except Exception as e: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) @router.patch("/{deck_id}", response_model=UserDeckResponse) async def update_user_deck( deck_id: int, request: UserDeckUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ) -> UserDeckResponse: """Update a user deck's metadata.""" user_id = _get_user_id(current_user) try: update_data = request.model_dump(exclude_unset=True) if "status" in update_data and update_data["status"]: update_data["status"] = update_data["status"].value updated_deck = await _deck_mgr.update_deck( db=db, deck_id=deck_id, user_id=user_id, **update_data, ) if not updated_deck: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") card_count = await _get_card_count(db, deck_id) return await _enrich_deck_response(updated_deck, card_count) except HTTPException: raise except ValueError as e: if "not found" in str(e).lower(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @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), ) -> MessageResponse: """Delete a user deck.""" user_id = _get_user_id(current_user) try: result = await _deck_mgr.delete_deck(db=db, deck_id=deck_id, user_id=user_id) if not result: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") return MessageResponse(message="Deck deleted successfully") except HTTPException: raise except Exception as e: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) # --------------------------------------------------------------------------- # 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), ) -> DeckFinalizeResponse: """Transition a deck from DRAFT to FINAL status.""" user_id = _get_user_id(current_user) try: deck = await _deck_mgr.finalize_deck(db=db, deck_id=deck_id, user_id=user_id) if not deck: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found") return DeckFinalizeResponse( deck_id=deck_id, status="FINAL", message="Deck finalized successfully", ) except HTTPException: raise except ValueError as e: if "not found" in str(e).lower(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) # --------------------------------------------------------------------------- # 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), ) -> DeckCardResponse: """Add a card to a user's deck. If a card already exists in the same zone, the quantity is incremented rather than creating a duplicate entry. """ user_id = _get_user_id(current_user) deck = await _verify_deck_ownership(db, deck_id, user_id) 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, ) 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), ) -> DeckCardListResponse: """Get all cards in a user's deck, optionally filtered by zone.""" user_id = _get_user_id(current_user) await _verify_deck_ownership(db, deck_id, user_id) # Use service to get cards deck_cards = await _deck_mgr.get_deck_cards(db=db, deck_id=deck_id, zone=zone) # Fetch card details from local mirror card_ids = [dc.card_id for dc in deck_cards] card_details: dict[int, MtgonlineCard] = {} 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: list[DeckCardWithDetailsResponse] = [] 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), ) -> DeckCardResponse: """Update a card's quantity or zone in a deck.""" user_id = _get_user_id(current_user) deck = await _verify_deck_ownership(db, deck_id, user_id) 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), ) -> MessageResponse: """Remove a card from a user's deck.""" user_id = _get_user_id(current_user) deck = await _verify_deck_ownership(db, deck_id, user_id) 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 # --------------------------------------------------------------------------- # Note: Precedent endpoints use direct DB operations as DeckManager # does not yet have precedent-specific methods. @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), ) -> PrecedentListResponse: """List available (public) deck precedents, paginated.""" offset = (page - 1) * page_size conditions = [DeckPrecedent.is_public == True] # noqa: E712 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: dict[int, int] = {} 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: list[PrecedentResponse] = [] 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), ) -> PrecedentResponse: """Create a deck precedent (template).""" user_id = _get_user_id(current_user) 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), ) -> PrecedentResponse: """Get a specific deck precedent by ID.""" 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") card_count = await _get_card_count(db, precedent_id) 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), ) -> dict: """Clone a precedent into a new draft deck for the current user.""" user_id = _get_user_id(current_user) try: new_deck = await _deck_mgr.clone_precedent( db=db, precedent_id=precedent_id, user_id=user_id, ) card_count = len(await _deck_mgr.get_deck_cards(db=db, deck_id=new_deck.id)) return { "message": "Precedent cloned into new deck", "deck_id": new_deck.id, "deck_name": new_deck.name, "card_count": card_count, } except ValueError as e: if "not found" in str(e).lower(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) # --------------------------------------------------------------------------- # 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), ) -> CardSearchResponse: """Search MTG cards for use in deck building. Searches across name, type line, and mana cost fields in the local card mirror. """ 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: list[dict] = [] 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 # --------------------------------------------------------------------------- # Note: Suggestion endpoints use direct DB operations as DeckManager # does not yet have suggestion-specific methods. @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), ) -> SuggestionListResponse: """Get card suggestions for a deck, optionally filtered by type.""" user_id = _get_user_id(current_user) await _verify_deck_ownership(db, deck_id, user_id) 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: dict[int, str] = {} 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: list[SuggestionResponse] = [] 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), ) -> SuggestionResponse: """Add a card suggestion to a deck.""" user_id = _get_user_id(current_user) deck = await _verify_deck_ownership(db, deck_id, user_id) 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)