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>
297 lines
8.7 KiB
Python
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,
|
|
)
|