- Add UserRepository and LinkedAccountRepository protocols to protocols.py - Add UserEntry and LinkedAccountEntry DTOs for service layer decoupling - Implement PostgresUserRepository and PostgresLinkedAccountRepository - Refactor UserService to use constructor-injected repositories - Add get_user_service factory and UserServiceDep to API deps - Update auth.py and users.py endpoints to use UserServiceDep - Rewrite tests to use FastAPI dependency overrides (no monkey patching) This follows the established repository pattern used by DeckService and CollectionService, enabling future offline fork support. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
593 lines
16 KiB
Python
593 lines
16 KiB
Python
"""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.
|
|
"""
|
|
...
|