The frontend routing guard checks has_starter_deck to decide whether to redirect users to starter selection. The field was missing from the API response, causing authenticated users with a starter deck to be incorrectly redirected to /starter on page refresh. - Add has_starter_deck computed property to User model - Add has_starter_deck field to UserResponse schema - Add unit tests for User model properties - Add API tests for has_starter_deck in profile response Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
168 lines
4.7 KiB
Python
168 lines
4.7 KiB
Python
"""User model for Mantimon TCG.
|
|
|
|
This module defines the User model for player accounts with OAuth support
|
|
and premium subscription tracking.
|
|
|
|
Example:
|
|
user = User(
|
|
email="player@example.com",
|
|
display_name="Player1",
|
|
oauth_provider="google",
|
|
oauth_id="123456789"
|
|
)
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING
|
|
|
|
from sqlalchemy import Boolean, DateTime, Index, String
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.db.base import Base
|
|
|
|
if TYPE_CHECKING:
|
|
from app.db.models.campaign import CampaignProgress
|
|
from app.db.models.collection import Collection
|
|
from app.db.models.deck import Deck
|
|
from app.db.models.oauth_account import OAuthLinkedAccount
|
|
|
|
|
|
class User(Base):
|
|
"""User account model with OAuth and premium support.
|
|
|
|
Attributes:
|
|
id: Unique identifier (UUID).
|
|
email: User's email address (unique).
|
|
display_name: Public display name.
|
|
avatar_url: URL to user's avatar image.
|
|
oauth_provider: OAuth provider name ('google', 'discord').
|
|
oauth_id: Unique ID from the OAuth provider.
|
|
is_premium: Whether user has active premium subscription.
|
|
premium_until: When premium subscription expires.
|
|
created_at: Account creation timestamp.
|
|
last_login: Last login timestamp.
|
|
|
|
Relationships:
|
|
decks: User's deck configurations.
|
|
collection: User's card collection.
|
|
campaign_progress: User's campaign state.
|
|
linked_accounts: Additional linked OAuth providers.
|
|
"""
|
|
|
|
__tablename__ = "users"
|
|
|
|
# Basic info
|
|
email: Mapped[str] = mapped_column(
|
|
String(255),
|
|
unique=True,
|
|
nullable=False,
|
|
index=True,
|
|
doc="User's email address",
|
|
)
|
|
display_name: Mapped[str] = mapped_column(
|
|
String(50),
|
|
nullable=False,
|
|
doc="Public display name",
|
|
)
|
|
avatar_url: Mapped[str | None] = mapped_column(
|
|
String(500),
|
|
nullable=True,
|
|
doc="URL to avatar image",
|
|
)
|
|
|
|
# OAuth
|
|
oauth_provider: Mapped[str] = mapped_column(
|
|
String(20),
|
|
nullable=False,
|
|
doc="OAuth provider name (google, discord)",
|
|
)
|
|
oauth_id: Mapped[str] = mapped_column(
|
|
String(255),
|
|
nullable=False,
|
|
doc="Unique ID from OAuth provider",
|
|
)
|
|
|
|
# Premium subscription
|
|
is_premium: Mapped[bool] = mapped_column(
|
|
Boolean,
|
|
default=False,
|
|
nullable=False,
|
|
doc="Whether user has active premium",
|
|
)
|
|
premium_until: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
doc="Premium subscription expiration",
|
|
)
|
|
|
|
# Activity tracking
|
|
last_login: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
doc="Last login timestamp",
|
|
)
|
|
|
|
# Relationships
|
|
decks: Mapped[list["Deck"]] = relationship(
|
|
"Deck",
|
|
back_populates="user",
|
|
cascade="all, delete-orphan",
|
|
lazy="selectin",
|
|
)
|
|
collection: Mapped[list["Collection"]] = relationship(
|
|
"Collection",
|
|
back_populates="user",
|
|
cascade="all, delete-orphan",
|
|
lazy="selectin",
|
|
)
|
|
campaign_progress: Mapped["CampaignProgress | None"] = relationship(
|
|
"CampaignProgress",
|
|
back_populates="user",
|
|
cascade="all, delete-orphan",
|
|
uselist=False,
|
|
lazy="selectin",
|
|
)
|
|
linked_accounts: Mapped[list["OAuthLinkedAccount"]] = relationship(
|
|
"OAuthLinkedAccount",
|
|
back_populates="user",
|
|
cascade="all, delete-orphan",
|
|
lazy="selectin",
|
|
)
|
|
|
|
# Indexes
|
|
__table_args__ = (Index("ix_users_oauth", "oauth_provider", "oauth_id", unique=True),)
|
|
|
|
@property
|
|
def has_active_premium(self) -> bool:
|
|
"""Check if premium subscription is currently active.
|
|
|
|
Returns:
|
|
True if user has premium and it hasn't expired.
|
|
"""
|
|
if not self.is_premium:
|
|
return False
|
|
if self.premium_until is None:
|
|
return False
|
|
return datetime.now(self.premium_until.tzinfo) < self.premium_until
|
|
|
|
@property
|
|
def max_decks(self) -> int:
|
|
"""Get maximum number of decks user can have.
|
|
|
|
Returns:
|
|
5 for free users, unlimited (999) for premium.
|
|
"""
|
|
return 999 if self.has_active_premium else 5
|
|
|
|
@property
|
|
def has_starter_deck(self) -> bool:
|
|
"""Check if user has selected a starter deck.
|
|
|
|
Returns:
|
|
True if any of the user's decks is marked as a starter deck.
|
|
"""
|
|
return any(deck.is_starter for deck in self.decks)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<User(id={self.id!r}, email={self.email!r})>"
|