mantimon-tcg/backend/app/api/decks.py
Cal Corum 7d397a2e22 Fix medium priority issues from code review
UNSET sentinel pattern:
- Add UNSET sentinel in protocols.py for nullable field updates
- Fix inability to clear deck description (UNSET=keep, None=clear)
- Fix repository inability to clear validation_errors

Starter deck improvements:
- Remove unused has_starter_deck from CollectionService
- Add deprecation notes to old starter deck methods

Validation improvements:
- Add energy type validation in deck_validator.py
- Add energy type validation in deck schemas
- Add VALID_ENERGY_TYPES constant

Game loading fix:
- Fix get_deck_for_game silently skipping invalid cards
- Now raises ValueError with clear error message

Tests:
- Add TestEnergyTypeValidation test class
- Add TestGetDeckForGame test class
- Add tests for validate_energy_types utility function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:32:08 -06:00

297 lines
8.7 KiB
Python

"""Decks API router for Mantimon TCG.
This module provides REST endpoints for deck management including
creating, reading, updating, and deleting decks, as well as validation.
The backend is stateless - deck rules come from the request via DeckConfig.
Frontend provides the rules appropriate for the game mode (campaign, freeplay, custom).
Endpoints:
GET /decks - List all user's decks
POST /decks - Create a new deck
GET /decks/{deck_id} - Get a specific deck
PUT /decks/{deck_id} - Update a deck
DELETE /decks/{deck_id} - Delete a deck
POST /decks/validate - Validate a deck without saving
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CardServiceDep, CollectionServiceDep, CurrentUser, DeckServiceDep
from app.repositories.protocols import UNSET
from app.schemas.deck import (
DeckCreateRequest,
DeckListResponse,
DeckResponse,
DeckUpdateRequest,
DeckValidateRequest,
DeckValidationResponse,
)
from app.services.deck_service import DeckLimitExceededError, DeckNotFoundError
from app.services.deck_validator import validate_deck
router = APIRouter(prefix="/decks", tags=["decks"])
@router.get("", response_model=DeckListResponse)
async def list_decks(
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckListResponse:
"""Get all decks for the authenticated user.
Returns a list of all user's decks along with count and limit information.
Premium users have unlimited decks (deck_limit=None).
Returns:
DeckListResponse with decks, count, and limit.
"""
decks = await deck_service.get_user_decks(current_user.id)
# Convert DeckEntry DTOs to DeckResponse
deck_responses = [
DeckResponse(
id=d.id,
name=d.name,
description=d.description,
cards=d.cards,
energy_cards=d.energy_cards,
is_valid=d.is_valid,
validation_errors=d.validation_errors,
is_starter=d.is_starter,
starter_type=d.starter_type,
created_at=d.created_at,
updated_at=d.updated_at,
)
for d in decks
]
# Premium users have unlimited decks
deck_limit = None if current_user.has_active_premium else current_user.max_decks
return DeckListResponse(
decks=deck_responses,
deck_count=len(decks),
deck_limit=deck_limit,
)
@router.post("", response_model=DeckResponse, status_code=status.HTTP_201_CREATED)
async def create_deck(
deck_in: DeckCreateRequest,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckResponse:
"""Create a new deck.
Validates the deck composition and stores it. Invalid decks CAN be saved
(with validation errors) to support work-in-progress deck building.
Args:
deck_in: Deck creation request with name, cards, energy, and config.
Returns:
The created deck with validation state.
Raises:
400: If user has reached their deck limit.
"""
try:
deck = await deck_service.create_deck(
user_id=current_user.id,
name=deck_in.name,
cards=deck_in.cards,
energy_cards=deck_in.energy_cards,
deck_config=deck_in.deck_config,
max_decks=current_user.max_decks,
validate_ownership=deck_in.validate_ownership,
description=deck_in.description,
)
except DeckLimitExceededError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
return DeckResponse(
id=deck.id,
name=deck.name,
description=deck.description,
cards=deck.cards,
energy_cards=deck.energy_cards,
is_valid=deck.is_valid,
validation_errors=deck.validation_errors,
is_starter=deck.is_starter,
starter_type=deck.starter_type,
created_at=deck.created_at,
updated_at=deck.updated_at,
)
@router.get("/{deck_id}", response_model=DeckResponse)
async def get_deck(
deck_id: UUID,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckResponse:
"""Get a specific deck by ID.
Only returns decks owned by the authenticated user.
Args:
deck_id: The deck's UUID.
Returns:
The deck details.
Raises:
404: If deck not found or not owned by user.
"""
try:
deck = await deck_service.get_deck(current_user.id, deck_id)
except DeckNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
) from None
return DeckResponse(
id=deck.id,
name=deck.name,
description=deck.description,
cards=deck.cards,
energy_cards=deck.energy_cards,
is_valid=deck.is_valid,
validation_errors=deck.validation_errors,
is_starter=deck.is_starter,
starter_type=deck.starter_type,
created_at=deck.created_at,
updated_at=deck.updated_at,
)
@router.put("/{deck_id}", response_model=DeckResponse)
async def update_deck(
deck_id: UUID,
deck_in: DeckUpdateRequest,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckResponse:
"""Update an existing deck.
Only provided fields are updated. If cards or energy_cards change,
the deck is re-validated with the provided deck_config.
To clear the description, explicitly send `"description": null` in the request.
Omitting the field keeps the existing description.
Args:
deck_id: The deck's UUID.
deck_in: Update request with optional fields.
Returns:
The updated deck.
Raises:
404: If deck not found or not owned by user.
"""
# Check if description was explicitly provided (even if null)
# UNSET means "keep existing", whereas None means "clear"
description = deck_in.description if "description" in deck_in.model_fields_set else UNSET
try:
deck = await deck_service.update_deck(
user_id=current_user.id,
deck_id=deck_id,
deck_config=deck_in.deck_config,
name=deck_in.name,
cards=deck_in.cards,
energy_cards=deck_in.energy_cards,
validate_ownership=deck_in.validate_ownership,
description=description,
)
except DeckNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
) from None
return DeckResponse(
id=deck.id,
name=deck.name,
description=deck.description,
cards=deck.cards,
energy_cards=deck.energy_cards,
is_valid=deck.is_valid,
validation_errors=deck.validation_errors,
is_starter=deck.is_starter,
starter_type=deck.starter_type,
created_at=deck.created_at,
updated_at=deck.updated_at,
)
@router.delete("/{deck_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_deck(
deck_id: UUID,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> None:
"""Delete a deck.
Only deletes decks owned by the authenticated user.
Args:
deck_id: The deck's UUID.
Raises:
404: If deck not found or not owned by user.
"""
try:
await deck_service.delete_deck(current_user.id, deck_id)
except DeckNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
) from None
@router.post("/validate", response_model=DeckValidationResponse)
async def validate_deck_endpoint(
request: DeckValidateRequest,
current_user: CurrentUser,
card_service: CardServiceDep,
collection_service: CollectionServiceDep,
) -> DeckValidationResponse:
"""Validate a deck composition without saving.
Useful for checking if a deck is valid before creating it.
Uses the provided deck_config rules from the frontend.
Args:
request: Validation request with cards, energy, and config.
Returns:
Validation result with is_valid and any errors.
"""
# Get owned cards if validating ownership (campaign mode)
owned_cards: dict[str, int] | None = None
if request.validate_ownership:
owned_cards = await collection_service.get_owned_cards_dict(current_user.id)
# Use the pure validation function with config from request
result = validate_deck(
cards=request.cards,
energy_cards=request.energy_cards,
deck_config=request.deck_config,
card_lookup=card_service.get_card,
owned_cards=owned_cards,
)
return DeckValidationResponse(
is_valid=result.is_valid,
errors=result.errors,
)