mantimon-tcg/backend/app/db/models/collection.py
Cal Corum 50684a1b11 Add database infrastructure with SQLAlchemy models and test suite
Phase 1 Database Implementation (DB-001 through DB-012):

Models:
- User: OAuth support (Google/Discord), premium subscriptions
- Collection: Card ownership with CardSource enum
- Deck: JSONB cards/energy_cards, validation state
- CampaignProgress: One-to-one with User, medals/NPCs as JSONB
- ActiveGame: In-progress games with GameType enum
- GameHistory: Completed games with EndReason enum, replay data

Infrastructure:
- Alembic migrations with sync psycopg2 (avoids async issues)
- Docker Compose for Postgres (5433) and Redis (6380)
- App config with Pydantic settings
- Redis client helper

Test Infrastructure:
- 68 database tests (47 model + 21 relationship)
- Async factory pattern for test data creation
- Sync TRUNCATE cleanup (solves pytest-asyncio event loop mismatch)
- Uses dev containers instead of testcontainers for reliability

Key technical decisions:
- passive_deletes=True for ON DELETE SET NULL relationships
- NullPool for test sessions (no connection reuse)
- expire_on_commit=False with manual expire() for relationship tests
2026-01-27 10:17:30 -06:00

136 lines
3.6 KiB
Python

"""Collection model for Mantimon TCG.
This module defines the Collection model for tracking player card ownership.
Each entry represents a card the player owns, with quantity and source tracking.
Example:
collection_entry = Collection(
user_id=user.id,
card_definition_id="pikachu_base_001",
quantity=3,
source=CardSource.BOOSTER
)
"""
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy import (
Enum as SQLEnum,
)
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 CardSource(str, Enum):
"""Source of how a card was obtained.
Attributes:
STARTER: From a starter deck selection.
BOOSTER: From opening a booster pack.
REWARD: From winning matches or achievements.
PURCHASE: From direct purchase with Mantibucks.
TRADE: From trading with another player.
GIFT: From promotional events or gifts.
"""
STARTER = "starter"
BOOSTER = "booster"
REWARD = "reward"
PURCHASE = "purchase"
TRADE = "trade"
GIFT = "gift"
class Collection(Base):
"""Player card collection entry.
Tracks ownership of cards with quantity and source information.
Each (user_id, card_definition_id) pair is unique - quantities are aggregated.
Attributes:
id: Unique identifier (UUID).
user_id: Foreign key to the owning user.
card_definition_id: ID of the card definition (e.g., "pikachu_base_001").
quantity: Number of copies owned (1+).
source: How the first copy was obtained.
obtained_at: When the card was first added to collection.
created_at: Record creation timestamp.
updated_at: Record update timestamp.
Relationships:
user: The user who owns this card.
"""
__tablename__ = "collections"
# Ownership
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
doc="Foreign key to owning user",
)
# Card reference (string ID matching card JSON data)
card_definition_id: Mapped[str] = mapped_column(
String(100),
nullable=False,
doc="Card definition ID (e.g., 'pikachu_base_001')",
)
# Quantity tracking
quantity: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
doc="Number of copies owned",
)
# Source tracking
source: Mapped[CardSource] = mapped_column(
SQLEnum(CardSource, name="card_source", create_constraint=True),
nullable=False,
doc="How the card was obtained",
)
# When first obtained
obtained_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
doc="When card was first added to collection",
)
# Relationship
user: Mapped["User"] = relationship(
"User",
back_populates="collection",
)
# Constraints and indexes
__table_args__ = (
UniqueConstraint("user_id", "card_definition_id", name="uq_collection_user_card"),
Index("ix_collection_card", "card_definition_id"),
)
def __repr__(self) -> str:
return (
f"<Collection(user_id={self.user_id!r}, "
f"card={self.card_definition_id!r}, qty={self.quantity})>"
)