- Removed 11 duplicate router files (auth.py, users.py, decks.py, card_router.py, interactions.py, user_data.py, rooms.py, games/router.py, refresh.py, card_import.py, ws.py) - Removed duplicate core files (logging_config.py, settings.py) - Removed unused services (card_mirror_service.py, mtgjson_loader.py, mtgjson_uploader.py) - Removed unused schemas (proto_messages.py, protocol_constants.py) - Created run.py startup script for simple execution - Created app/core/config.py for unified configuration management - Created app/core/logging.py for logging configuration - Consolidated routers into 6 focused files (auth_router.py, users_router.py, decks_router.py, cards_router.py, user_data_router.py, games_router.py) - Updated all imports to use consolidated model paths - Added backward compatibility aliases in database.py - Created SIMPLIFICATION_SUMMARY.md documentation Architecture improvements: - Single unified database engine with auto-detection (SQLite/PostgreSQL) - Consolidated models into 2 files (models.py, mtg_models.py) - Clean router structure with one file per domain - Simplified configuration with sensible defaults - SQLite by default for development, PostgreSQL for production Testing: - All imports verified working - App loads successfully - Ready for development and testing
811 lines
28 KiB
Python
811 lines
28 KiB
Python
"""
|
|
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)
|