""" 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, and_, or_ 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, CardSearchResponse, MessageResponse, CountResponse ) router = APIRouter() # ===== 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"]) 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: 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() # 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_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 UserDeckListResponse( decks=deck_responses, total=total, page=page, page_size=page_size, total_pages=(total + page_size - 1) // page_size, ) @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"]) # Verify folder exists if specified if request.folder_id: stmt = select(DecklistFolder).where( DecklistFolder.id == request.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") new_deck = UserDeck( 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, ) db.add(new_deck) await db.flush() deck_data = UserDeckResponse.model_validate(new_deck) deck_data.card_count = 0 deck_data.is_owner = True return deck_data @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"]) 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") # 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 @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"]) 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") # Update fields update_data = request.model_dump(exclude_unset=True) 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(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(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 @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"]) 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") 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, ) 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)