mantimon-tcg/backend/app/db/models/user.py
Cal Corum 996c43fbd9 Implement Phase 2: Authentication system
Complete OAuth-based authentication with JWT session management:

Core Services:
- JWT service for access/refresh token creation and verification
- Token store with Redis-backed refresh token revocation
- User service for CRUD operations and OAuth-based creation
- Google and Discord OAuth services with full flow support

API Endpoints:
- GET /api/auth/{google,discord} - Start OAuth flows
- GET /api/auth/{google,discord}/callback - Handle OAuth callbacks
- POST /api/auth/refresh - Exchange refresh token for new access token
- POST /api/auth/logout - Revoke single refresh token
- POST /api/auth/logout-all - Revoke all user sessions
- GET/PATCH /api/users/me - User profile management
- GET /api/users/me/linked-accounts - List OAuth providers
- GET /api/users/me/sessions - Count active sessions

Infrastructure:
- Pydantic schemas for auth/user request/response models
- FastAPI dependencies (get_current_user, get_current_premium_user)
- OAuthLinkedAccount model for multi-provider support
- Alembic migration for oauth_linked_accounts table

Dependencies added: email-validator, fakeredis (dev), respx (dev)

84 new tests, 1058 total passing
2026-01-27 21:49:59 -06:00

159 lines
4.4 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
def __repr__(self) -> str:
return f"<User(id={self.id!r}, email={self.email!r})>"