mantimon-tcg/backend/app/services/collection_service.py
Cal Corum 3ec670753b Fix security and validation issues from code review
Critical fixes:
- Add admin API key authentication for admin endpoints
- Add race condition protection via unique partial index for starter decks
- Make starter deck selection atomic with combined method

Moderate fixes:
- Fix DI pattern violation in validate_deck_endpoint
- Add card ID format validation (regex pattern)
- Add card quantity validation (1-99 range)
- Fix exception chaining with from None (B904)

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

273 lines
8.6 KiB
Python

"""Collection service for Mantimon TCG.
This module provides business logic for user card collections. It uses
the CollectionRepository protocol for data access, enabling easy testing
and multiple storage backends (PostgreSQL, SQLite, local files).
The service layer handles:
- Card ID validation via CardService
- Starter deck granting logic
- Collection statistics
Example:
from app.services.card_service import CardService
from app.services.collection_service import CollectionService
from app.repositories.postgres import PostgresCollectionRepository
# Create dependencies
card_service = CardService()
card_service.load_all()
# Create repository and service
repo = PostgresCollectionRepository(db_session)
service = CollectionService(repo, card_service)
# Add cards to collection
entry = await service.add_cards(
user_id, "a1-001-bulbasaur", quantity=2, source=CardSource.BOOSTER
)
"""
from uuid import UUID
from app.db.models.collection import CardSource
from app.repositories.protocols import CollectionEntry, CollectionRepository
from app.services.card_service import CardService
class CollectionService:
"""Service for card collection business logic.
Uses repository pattern for data access, enabling:
- Easy unit testing with mock repositories
- Multiple storage backends
- Offline fork support
Attributes:
_repo: The collection repository implementation.
_card_service: The card service for card validation.
"""
def __init__(self, repository: CollectionRepository, card_service: CardService) -> None:
"""Initialize with dependencies.
Args:
repository: Implementation of CollectionRepository protocol.
card_service: Card service for validating card IDs.
"""
self._repo = repository
self._card_service = card_service
async def get_collection(self, user_id: UUID) -> list[CollectionEntry]:
"""Get all cards in a user's collection.
Args:
user_id: The user's UUID.
Returns:
List of CollectionEntry for the user.
Example:
collection = await service.get_collection(user_id)
for entry in collection:
print(f"{entry.card_definition_id}: {entry.quantity}")
"""
return await self._repo.get_all(user_id)
async def get_card_quantity(self, user_id: UUID, card_definition_id: str) -> int:
"""Get quantity of a specific card owned by user.
Args:
user_id: The user's UUID.
card_definition_id: Card ID to check.
Returns:
Number of copies owned (0 if not owned).
"""
return await self._repo.get_quantity(user_id, card_definition_id)
async def get_collection_entry(
self, user_id: UUID, card_definition_id: str
) -> CollectionEntry | None:
"""Get a specific collection entry.
Args:
user_id: The user's UUID.
card_definition_id: Card ID to look up.
Returns:
CollectionEntry if exists, None otherwise.
"""
return await self._repo.get_by_card(user_id, card_definition_id)
async def add_cards(
self,
user_id: UUID,
card_definition_id: str,
quantity: int,
source: CardSource,
) -> CollectionEntry:
"""Add cards to a user's collection.
Validates that the card ID exists before adding. Uses upsert
pattern: creates new entry if card not owned, or increments
quantity if already owned.
Args:
user_id: The user's UUID.
card_definition_id: Card ID to add.
quantity: Number of copies to add.
source: How the cards were obtained.
Returns:
The created or updated CollectionEntry.
Raises:
ValueError: If card_definition_id doesn't exist.
Example:
entry = await service.add_cards(
user_id, "a1-001-bulbasaur",
quantity=2, source=CardSource.BOOSTER
)
"""
# Validate card exists
if self._card_service.get_card(card_definition_id) is None:
raise ValueError(f"Invalid card ID: {card_definition_id}")
return await self._repo.upsert(user_id, card_definition_id, quantity, source)
async def remove_cards(
self,
user_id: UUID,
card_definition_id: str,
quantity: int,
) -> CollectionEntry | None:
"""Remove cards from a user's collection.
Decrements quantity. If quantity reaches 0, deletes the entry.
Args:
user_id: The user's UUID.
card_definition_id: Card ID to remove.
quantity: Number of copies to remove.
Returns:
Updated CollectionEntry, or None if card not owned
or all copies were removed.
"""
return await self._repo.decrement(user_id, card_definition_id, quantity)
async def has_cards(
self,
user_id: UUID,
card_requirements: dict[str, int],
) -> bool:
"""Check if user owns at least the required quantity of each card.
Args:
user_id: The user's UUID.
card_requirements: Mapping of card IDs to required quantities.
Returns:
True if user owns enough of all cards, False otherwise.
Example:
can_build = await service.has_cards(
user_id, {"a1-001-bulbasaur": 4, "a1-002-ivysaur": 2}
)
"""
for card_id, required_qty in card_requirements.items():
owned_qty = await self._repo.get_quantity(user_id, card_id)
if owned_qty < required_qty:
return False
return True
async def get_owned_cards_dict(self, user_id: UUID) -> dict[str, int]:
"""Get user's collection as a card_id -> quantity mapping.
Useful for deck validation in campaign mode.
Args:
user_id: The user's UUID.
Returns:
Dictionary mapping card IDs to quantities.
Example:
owned = await service.get_owned_cards_dict(user_id)
# Pass to validate_deck function
result = validate_deck(cards, energy, deck_config, card_lookup, owned_cards=owned)
"""
collection = await self._repo.get_all(user_id)
return {entry.card_definition_id: entry.quantity for entry in collection}
async def grant_starter_deck(
self,
user_id: UUID,
starter_type: str,
) -> list[CollectionEntry]:
"""Grant all cards from a starter deck to user's collection.
Uses CardSource.STARTER for all granted cards.
Args:
user_id: The user's UUID.
starter_type: Type of starter deck (grass, fire, water, etc.).
Returns:
List of CollectionEntry created/updated.
Raises:
ValueError: If starter_type is invalid.
Example:
entries = await service.grant_starter_deck(user_id, "grass")
"""
# Import here to avoid circular dependency
from app.data.starter_decks import STARTER_TYPES, get_starter_deck
if starter_type not in STARTER_TYPES:
raise ValueError(
f"Invalid starter type: {starter_type}. "
f"Must be one of: {', '.join(STARTER_TYPES)}"
)
starter_deck = get_starter_deck(starter_type)
entries: list[CollectionEntry] = []
# Add all cards from the deck
for card_id, quantity in starter_deck["cards"].items():
entry = await self.add_cards(user_id, card_id, quantity, CardSource.STARTER)
entries.append(entry)
return entries
async def has_starter_deck(self, user_id: UUID) -> bool:
"""Check if user has already received a starter deck.
Checks for any cards with STARTER source in collection.
Args:
user_id: The user's UUID.
Returns:
True if user has starter cards, False otherwise.
"""
return await self._repo.exists_with_source(user_id, CardSource.STARTER)
async def get_collection_stats(self, user_id: UUID) -> dict[str, int]:
"""Get aggregate statistics for user's collection.
Args:
user_id: The user's UUID.
Returns:
Dictionary with total_unique_cards and total_card_count.
"""
collection = await self._repo.get_all(user_id)
return {
"total_unique_cards": len(collection),
"total_card_count": sum(entry.quantity for entry in collection),
}