Files
mtgonline/backend/app/routers/decks.py
T
akadmin 1df04aea52 Phase 7: End-to-End API Testing - Database setup, migrations, and application fixes
- Switched from psycopg2 to asyncpg for async SQLAlchemy support
- Fixed router registration in main.py - removed duplicate prefixes
- Added user_data export to routers/__init__.py
- Refactored decks router to use DeckManager service layer
- Integrated FuzzyCardMatcher into card_router search endpoints
- Made WishlistCreate.card_id optional for proper schema validation
- Set PostgreSQL password and configured scram-sha-256 auth
- Updated alembic.ini to use local PostgreSQL instead of Docker hostname
- Created generic_schemas.py for reusable schema patterns
- Added test_routers.py and test_schema_validation.py test files
- All 6 Alembic migrations applied successfully (37 tables created)
- Application running on port 8000 with all services connected
2026-08-18 03:35:19 +00:00

722 lines
25 KiB
Python

"""
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)