"""Repository protocol definitions for Mantimon TCG. This module defines the abstract interfaces (Protocols) for data access. Concrete implementations can be PostgreSQL, SQLite, or in-memory storage. The protocols define WHAT operations are available, not HOW they're implemented. This enables the offline fork to implement LocalCollectionRepository while the backend uses PostgresCollectionRepository. Example: class CollectionRepository(Protocol): async def get_all(self, user_id: UUID) -> list[CollectionEntry]: ... # PostgreSQL implementation class PostgresCollectionRepository: def __init__(self, db: AsyncSession): ... async def get_all(self, user_id: UUID) -> list[CollectionEntry]: ... # Local/offline implementation class LocalCollectionRepository: def __init__(self, storage_path: Path): ... async def get_all(self, user_id: UUID) -> list[CollectionEntry]: ... """ from dataclasses import dataclass from datetime import datetime from typing import Any, Protocol from uuid import UUID from app.db.models.collection import CardSource # ============================================================================= # Sentinel Value for Optional Updates # ============================================================================= # UNSET is used to distinguish between "not provided" (keep existing) # and "explicitly set to None" (clear the field). # # Example usage: # async def update(description: str | None = UNSET): # if description is not UNSET: # # User explicitly provided a value (could be None to clear) # record.description = description class _UnsetType: """Sentinel class for unset optional parameters.""" __slots__ = () def __repr__(self) -> str: return "UNSET" def __bool__(self) -> bool: return False UNSET: Any = _UnsetType() # ============================================================================= # Data Transfer Objects (DTOs) # ============================================================================= # These are storage-agnostic representations used by protocols. # They decouple the service layer from ORM models. @dataclass class CollectionEntry: """Storage-agnostic representation of a collection entry. This DTO decouples the service layer from the ORM model, allowing different storage backends to return the same structure. """ id: UUID user_id: UUID card_definition_id: str quantity: int source: CardSource obtained_at: datetime created_at: datetime updated_at: datetime @dataclass class DeckEntry: """Storage-agnostic representation of a deck. This DTO decouples the service layer from the ORM model. """ id: UUID user_id: UUID name: str cards: dict[str, int] energy_cards: dict[str, int] is_valid: bool validation_errors: list[str] | None is_starter: bool starter_type: str | None description: str | None created_at: datetime updated_at: datetime @dataclass class UserEntry: """Storage-agnostic representation of a user account. This DTO decouples the service layer from the ORM model, enabling different storage backends (PostgreSQL, SQLite, JSON) to be used interchangeably. """ id: UUID email: str display_name: str avatar_url: str | None oauth_provider: str oauth_id: str is_premium: bool premium_until: datetime | None last_login: datetime | None created_at: datetime updated_at: datetime @dataclass class LinkedAccountEntry: """Storage-agnostic representation of a linked OAuth account. Users can link multiple OAuth providers (e.g., Google + Discord) to a single account for flexible login options. """ id: UUID user_id: UUID provider: str oauth_id: str email: str | None display_name: str | None avatar_url: str | None linked_at: datetime # ============================================================================= # Repository Protocols # ============================================================================= class CollectionRepository(Protocol): """Protocol for card collection data access. Implementations handle storage-specific details (PostgreSQL, SQLite, JSON). Services use this protocol for business logic without knowing storage details. All methods are async to support both database and file-based storage. """ async def get_all(self, user_id: UUID) -> list[CollectionEntry]: """Get all collection entries for a user. Args: user_id: The user's UUID. Returns: List of all collection entries, ordered by card_definition_id. """ ... async def get_by_card(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: The card ID to look up. Returns: CollectionEntry if exists, None otherwise. """ ... async def get_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). """ ... async def upsert( self, user_id: UUID, card_definition_id: str, quantity: int, source: CardSource, ) -> CollectionEntry: """Add or update a collection entry. If entry exists, increments quantity. Otherwise creates new entry. 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. """ ... async def decrement( self, user_id: UUID, card_definition_id: str, quantity: int, ) -> CollectionEntry | None: """Decrement quantity of a collection entry. If quantity reaches 0, deletes the entry. Args: user_id: The user's UUID. card_definition_id: Card ID to decrement. quantity: Number of copies to remove. Returns: Updated entry, or None if entry was deleted or didn't exist. """ ... async def exists_with_source(self, user_id: UUID, source: CardSource) -> bool: """Check if user has any entries with the given source. Useful for checking if user has received a starter deck. Args: user_id: The user's UUID. source: The CardSource to check for. Returns: True if any entries exist with that source. """ ... class DeckRepository(Protocol): """Protocol for deck data access. Implementations handle storage-specific details (PostgreSQL, SQLite, JSON). Services use this protocol for business logic without knowing storage details. """ async def get_by_id(self, deck_id: UUID) -> DeckEntry | None: """Get a deck by its ID. Args: deck_id: The deck's UUID. Returns: DeckEntry if found, None otherwise. """ ... async def get_by_user(self, user_id: UUID) -> list[DeckEntry]: """Get all decks for a user. Args: user_id: The user's UUID. Returns: List of all user's decks, ordered by name. """ ... async def get_user_deck(self, user_id: UUID, deck_id: UUID) -> DeckEntry | None: """Get a specific deck owned by a user. Combines ownership check with retrieval. Args: user_id: The user's UUID. deck_id: The deck's UUID. Returns: DeckEntry if found and owned by user, None otherwise. """ ... async def count_by_user(self, user_id: UUID) -> int: """Count how many decks a user has. Args: user_id: The user's UUID. Returns: Number of decks owned by user. """ ... async def create( self, user_id: UUID, name: str, cards: dict[str, int], energy_cards: dict[str, int], is_valid: bool, validation_errors: list[str] | None, is_starter: bool = False, starter_type: str | None = None, description: str | None = None, ) -> DeckEntry: """Create a new deck. Args: user_id: The user's UUID. name: Display name for the deck. cards: Card ID to quantity mapping. energy_cards: Energy type to quantity mapping. is_valid: Whether deck passes validation. validation_errors: List of validation error messages. is_starter: Whether this is a starter deck. starter_type: Type of starter deck if applicable. description: Optional deck description. Returns: The created DeckEntry. """ ... async def update( self, deck_id: UUID, name: str | None = None, cards: dict[str, int] | None = None, energy_cards: dict[str, int] | None = None, is_valid: bool | None = None, validation_errors: list[str] | None = UNSET, # type: ignore[assignment] description: str | None = UNSET, # type: ignore[assignment] ) -> DeckEntry | None: """Update an existing deck. Only provided (non-UNSET) fields are updated. Use UNSET (default) to keep existing value, or None to clear. Args: deck_id: The deck's UUID. name: New name (optional, None keeps existing). cards: New card composition (optional, None keeps existing). energy_cards: New energy composition (optional, None keeps existing). is_valid: New validation status (optional, None keeps existing). validation_errors: New errors (UNSET=keep, None=clear, list=set). description: New description (UNSET=keep, None=clear, str=set). Returns: Updated DeckEntry, or None if deck not found. """ ... async def delete(self, deck_id: UUID) -> bool: """Delete a deck. Args: deck_id: The deck's UUID. Returns: True if deleted, False if not found. """ ... async def has_starter(self, user_id: UUID) -> tuple[bool, str | None]: """Check if user has a starter deck. Args: user_id: The user's UUID. Returns: Tuple of (has_starter, starter_type). """ ... class UserRepository(Protocol): """Protocol for user account data access. Implementations handle storage-specific details (PostgreSQL, SQLite, JSON). Services use this protocol for business logic without knowing storage details. Note: Business logic like get_or_create_from_oauth belongs in the service layer, not in the repository. """ async def get_by_id(self, user_id: UUID) -> UserEntry | None: """Get a user by their ID. Args: user_id: The user's UUID. Returns: UserEntry if found, None otherwise. """ ... async def get_by_email(self, email: str) -> UserEntry | None: """Get a user by their email address. Args: email: The user's email address. Returns: UserEntry if found, None otherwise. """ ... async def get_by_oauth(self, provider: str, oauth_id: str) -> UserEntry | None: """Get a user by their OAuth provider and ID. Args: provider: OAuth provider name (google, discord). oauth_id: Unique ID from the OAuth provider. Returns: UserEntry if found, None otherwise. """ ... async def create( self, email: str, display_name: str, oauth_provider: str, oauth_id: str, avatar_url: str | None = None, ) -> UserEntry: """Create a new user. Args: email: User's email address. display_name: Public display name. oauth_provider: OAuth provider name. oauth_id: Unique ID from the OAuth provider. avatar_url: Optional avatar URL. Returns: The created UserEntry. """ ... async def update( self, user_id: UUID, display_name: str | None = None, avatar_url: str | None = UNSET, # type: ignore[assignment] oauth_provider: str | None = None, oauth_id: str | None = None, ) -> UserEntry | None: """Update user profile fields. Only provided (non-None/non-UNSET) fields are updated. Use UNSET (default) to keep existing value for nullable fields, or None to explicitly clear them. Args: user_id: The user's UUID. display_name: New display name (None keeps existing). avatar_url: New avatar URL (UNSET=keep, None=clear, str=set). oauth_provider: New OAuth provider (None keeps existing). oauth_id: New OAuth ID (None keeps existing). Returns: Updated UserEntry, or None if user not found. """ ... async def update_last_login(self, user_id: UUID) -> UserEntry | None: """Update the user's last login timestamp to now. Args: user_id: The user's UUID. Returns: Updated UserEntry, or None if user not found. """ ... async def update_premium( self, user_id: UUID, is_premium: bool, premium_until: datetime | None, ) -> UserEntry | None: """Update user's premium subscription status. Args: user_id: The user's UUID. is_premium: Whether user has premium. premium_until: When premium expires, or None if not premium. Returns: Updated UserEntry, or None if user not found. """ ... async def delete(self, user_id: UUID) -> bool: """Delete a user account. This will cascade delete all related data (decks, collection, etc.) based on database constraints. Args: user_id: The user's UUID. Returns: True if deleted, False if not found. """ ... class LinkedAccountRepository(Protocol): """Protocol for OAuth linked accounts data access. Users can link multiple OAuth providers to a single account. The primary OAuth provider is stored on the User model itself; additional linked providers are stored as LinkedAccount records. """ async def get_by_provider( self, provider: str, oauth_id: str, ) -> LinkedAccountEntry | None: """Get a linked account by provider and OAuth ID. Args: provider: OAuth provider name (google, discord). oauth_id: Unique ID from the OAuth provider. Returns: LinkedAccountEntry if found, None otherwise. """ ... async def get_by_user(self, user_id: UUID) -> list[LinkedAccountEntry]: """Get all linked accounts for a user. Args: user_id: The user's UUID. Returns: List of LinkedAccountEntry, ordered by provider. """ ... async def create( self, user_id: UUID, provider: str, oauth_id: str, email: str | None = None, display_name: str | None = None, avatar_url: str | None = None, ) -> LinkedAccountEntry: """Link an OAuth provider to a user account. Args: user_id: The user's UUID. provider: OAuth provider name. oauth_id: Unique ID from the OAuth provider. email: Email from the OAuth provider. display_name: Display name from the OAuth provider. avatar_url: Avatar URL from the OAuth provider. Returns: The created LinkedAccountEntry. """ ... async def delete(self, user_id: UUID, provider: str) -> bool: """Unlink an OAuth provider from a user account. Args: user_id: The user's UUID. provider: OAuth provider name to unlink. Returns: True if deleted, False if not found. """ ...