mantimon-tcg/backend/app/db/models/user.py
Cal Corum ca3aca2b38 Add has_starter_deck to user profile API response
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>
2026-01-30 23:14:04 -06:00

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})>"