""" 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, func, or_, update, delete from typing import Optional, List from app.core.database import get_db, mtg_get_db from app.core.security import get_current_user 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, ) from app.schemas.card_search_schemas import CardSearchResponse from app.schemas.generic_schemas import MessageResponse, CountResponse from app.services.deck_manager import DeckManager from app.services.deck_parser import DeckParser router = APIRouter() _deck_mgr = DeckManager() _deck_parser = DeckParser() # ===== 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), ): """List user's decks with optional filtering.""" user_id = int(current_user["user_id"]) 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 = {} 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 = [] 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), ): """Create a new user deck (DRAFT status).""" user_id = int(current_user["user_id"]) 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), ): """Get a specific user deck.""" user_id = int(current_user["user_id"]) 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") # Get card count 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 deck_data = UserDeckResponse.model_validate(deck) deck_data.card_count = card_count deck_data.is_owner = True return deck_data 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), ): """Update a user deck.""" user_id = int(current_user["user_id"]) 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") # Get card count 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 deck_data = UserDeckResponse.model_validate(updated_deck) deck_data.card_count = card_count deck_data.is_owner = True return deck_data 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), ): """Delete a user deck.""" user_id = int(current_user["user_id"]) 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), ): """Transition a deck from DRAFT to FINAL status.""" user_id = int(current_user["user_id"]) 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), ): """Add a card to a user's deck.""" user_id = int(current_user["user_id"]) # Verify deck exists and belongs to user deck_stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id) deck_result = await db.execute(deck_stmt) deck = 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") # 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), ): """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") # 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 = {} 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 ===== # 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), ): """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"]) try: new_deck = await _deck_mgr.clone_precedent( db=db, precedent_id=precedent_id, user_id=user_id, ) return { "message": "Precedent cloned into new deck", "deck_id": new_deck.id, "deck_name": new_deck.name, "card_count": len(await _deck_mgr.get_deck_cards(db=db, deck_id=new_deck.id)), } 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), ): """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 ===== # 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), ): """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)