mantimon-tcg/backend/app/db/models/deck.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

172 lines
4.8 KiB
Python

"""Deck model for Mantimon TCG.
This module defines the Deck model for player deck configurations.
Decks store card lists as JSONB for flexibility and can be validated
against campaign rules or used freely in freeplay mode.
Example:
deck = Deck(
user_id=user.id,
name="Electric Storm",
cards={"pikachu_base_001": 2, "raichu_base_001": 1, ...},
energy_cards={"lightning": 10, "colorless": 4}
)
"""
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import Boolean, ForeignKey, Index, String, Text, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
if TYPE_CHECKING:
from app.db.models.user import User
class Deck(Base):
"""Player deck configuration.
Stores a deck's card composition and validation state.
Cards are stored as JSONB mapping card_definition_id -> quantity.
Attributes:
id: Unique identifier (UUID).
user_id: Foreign key to the owning user.
name: Display name for the deck.
cards: JSONB mapping card IDs to quantities.
energy_cards: JSONB mapping energy types to quantities.
is_valid: Whether deck passes validation rules.
validation_errors: JSONB list of validation error messages.
is_starter: Whether this is a pre-built starter deck.
starter_type: Type of starter deck (grass, fire, water, etc.).
created_at: Record creation timestamp.
updated_at: Record update timestamp.
Relationships:
user: The user who owns this deck.
Notes:
- Campaign mode requires deck ownership validation
- Freeplay mode unlocks all cards (validation skipped)
- Free users limited to 5 decks, premium unlimited
"""
__tablename__ = "decks"
# Ownership
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
doc="Foreign key to owning user",
)
# Deck identity
name: Mapped[str] = mapped_column(
String(100),
nullable=False,
doc="Display name for the deck",
)
# Card composition (JSONB for flexibility)
# Format: {"card_definition_id": quantity, ...}
cards: Mapped[dict] = mapped_column(
JSONB,
default=dict,
nullable=False,
doc="Mapping of card IDs to quantities",
)
# Energy cards tracked separately for easier UI display
# Format: {"lightning": 10, "fire": 5, ...}
energy_cards: Mapped[dict] = mapped_column(
JSONB,
default=dict,
nullable=False,
doc="Mapping of energy types to quantities",
)
# Validation state
is_valid: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
doc="Whether deck passes validation rules",
)
validation_errors: Mapped[list | None] = mapped_column(
JSONB,
nullable=True,
doc="List of validation error messages",
)
# Starter deck flags
is_starter: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
doc="Whether this is a pre-built starter deck",
)
starter_type: Mapped[str | None] = mapped_column(
String(20),
nullable=True,
doc="Type of starter deck (grass, fire, water, etc.)",
)
# Optional notes/description
description: Mapped[str | None] = mapped_column(
Text,
nullable=True,
doc="Optional deck description or notes",
)
# Relationship
user: Mapped["User"] = relationship(
"User",
back_populates="decks",
)
# Indexes
__table_args__ = (
Index("ix_decks_user_name", "user_id", "name"),
# Partial unique index: only one starter deck per user
Index(
"ix_decks_user_starter_unique",
"user_id",
unique=True,
postgresql_where=text("is_starter = true"),
),
)
@property
def total_cards(self) -> int:
"""Get total number of cards in deck (excluding energy).
Returns:
Sum of all card quantities.
"""
return sum(self.cards.values()) if self.cards else 0
@property
def total_energy(self) -> int:
"""Get total number of energy cards.
Returns:
Sum of all energy card quantities.
"""
return sum(self.energy_cards.values()) if self.energy_cards else 0
@property
def deck_size(self) -> int:
"""Get total deck size (cards + energy).
Returns:
Total cards in deck.
"""
return self.total_cards + self.total_energy
def __repr__(self) -> str:
return f"<Deck(id={self.id!r}, name={self.name!r}, size={self.deck_size})>"