Files
mtgonline/backend/app/models/models.py
T
akadmin e9f5377e0a Phase 5: Complete Core Game Engine implementation
- Added MTGOnlineGame, GamePlayer, GameHistory models
- Implemented full game CRUD endpoints (create, list, get, join, leave, start, end)
- Integrated WebSocket game server with database persistence
- Added Commander deck validation service (100-card min, color identity, ban list)
- Created Alembic migration 006 for new game tables
- All game endpoints now database-backed (no more stubs)
2026-08-27 05:29:38 +00:00

1060 lines
37 KiB
Python

"""
SQLAlchemy ORM models for the MTG Online application.
Consolidated from 9 fragmented model files (models.py, user_deck.py,
user_data.py, card_import_batch.py, user_card_import.py, relationships.py,
and others) into a single module for clarity and maintainability.
This file contains all application-level models:
- Core models: User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog
- Card mirror: MtgonlineCard (local copy of mtg_cards for deckbuilding)
- User decks: UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
- User data: UserSession, DeckVersion, GameReplay, ReplayPlayer, GameOutcome,
UserStatistics, UserCardCollection, CardWishlist, UserGroup, GroupMember,
GroupChatMessage, UserNetwork, NetworkMember, UserPreference, UserActivityLog
- Card imports: CardImportBatch, UserCardImportRecord, UserCardImport
Cross-model relationships (e.g., DecklistFile.card_links → DeckCardLink)
are resolved at the bottom of this file after all classes are defined.
"""
from sqlalchemy import (
Column, Integer, String, BigInteger, Boolean, DateTime, Text,
ForeignKey, Index, UniqueConstraint, Float, JSON,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
# ---------------------------------------------------------------------------
# Core Models
# ---------------------------------------------------------------------------
class User(Base):
"""User account model."""
__tablename__ = "mtgonline_users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(64), unique=True, nullable=False, index=True)
password_hash = Column(String(128), nullable=False) # bcrypt hash
salt = Column(String(128), nullable=False) # password salt
email = Column(String(255), nullable=True, index=True)
country = Column(String(2), nullable=True)
real_name = Column(String(128), nullable=True)
avatar_bmp = Column(Text, nullable=True) # Base64 encoded avatar
privlevel = Column(String(50), nullable=True, default="User")
# Account status
is_active = Column(Boolean, default=True)
is_banned = Column(Boolean, default=False)
ban_reason = Column(Text, nullable=True)
ban_ends = Column(DateTime, nullable=True)
# VIP/Donator status
vip_status = Column(Integer, default=0) # 0=normal, 1=vip, 2=donator
vip_expiry = Column(DateTime, nullable=True)
# Timestamps
creation_date = Column(DateTime, server_default=func.now())
last_login = Column(DateTime, nullable=True)
# Relationships
decklist_files = relationship(
"DecklistFile", back_populates="owner", cascade="all, delete-orphan"
)
decklist_folders = relationship(
"DecklistFolder", back_populates="owner", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<User {self.username} (ID: {self.id})>"
class DecklistFolder(Base):
"""User deck folder."""
__tablename__ = "mtgonline_decklist_folders"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False)
name = Column(String(255), nullable=False)
parent_id = Column(Integer, ForeignKey("mtgonline_decklist_folders.id"), nullable=True)
creation_date = Column(DateTime, server_default=func.now())
# Relationships
owner = relationship("User", back_populates="decklist_folders")
children = relationship(
"DecklistFolder", back_populates="parent", cascade="all, delete-orphan"
)
parent = relationship(
"DecklistFolder", back_populates="children", remote_side=[id]
)
files = relationship(
"DecklistFile", back_populates="folder", cascade="all, delete-orphan"
)
user_decks = relationship(
"UserDeck", back_populates="folder", lazy="select"
)
class DecklistFile(Base):
"""Deck file stored in a folder."""
__tablename__ = "mtgonline_decklist_files"
id = Column(Integer, primary_key=True, index=True)
folder_id = Column(Integer, ForeignKey("mtgonline_decklist_folders.id"), nullable=True)
owner_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False)
name = Column(String(255), nullable=False)
content = Column(Text, nullable=False) # Native XML or plain text deck format
format = Column(String(50), default="native") # 'native' or 'plain'
status = Column(String(20), default="DRAUGHT") # 'DRAUGHT' or 'FINAL'
creation_date = Column(DateTime, server_default=func.now())
# Relationships (card_links is added below after DeckCardLink is defined)
folder = relationship("DecklistFolder", back_populates="files")
owner = relationship("User", back_populates="decklist_files")
def __repr__(self) -> str:
return f"<DecklistFile {self.name} (ID: {self.id})>"
class Room(Base):
"""Chat room."""
__tablename__ = "mtgonline_rooms"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text, nullable=True)
is_password_protected = Column(Boolean, default=False)
password_hash = Column(String(128), nullable=True)
creation_date = Column(DateTime, server_default=func.now())
# Game types available in this room
game_types = relationship(
"RoomGameType", back_populates="room", cascade="all, delete-orphan"
)
class RoomGameType(Base):
"""Game type definition for a room."""
__tablename__ = "mtgonline_rooms_gametypes"
id = Column(Integer, primary_key=True, index=True)
room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
# Relationships
room = relationship("Room", back_populates="game_types")
class Ban(Base):
"""User ban record."""
__tablename__ = "mtgonline_bans"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False)
server_id = Column(Integer, nullable=True)
reason = Column(Text, nullable=False)
moderators = Column(String(255), nullable=True) # Admin usernames
ip_address = Column(String(45), nullable=True) # IPv4 or IPv6
expiration_time = Column(DateTime, nullable=True)
active = Column(Boolean, default=True)
creation_date = Column(DateTime, server_default=func.now())
# Relationships
user = relationship("User")
def __repr__(self) -> str:
return f"<Ban for User {self.user_id} (ID: {self.id})>"
class GameLog(Base):
"""Game log entry."""
__tablename__ = "mtgonline_log"
id = Column(Integer, primary_key=True, index=True)
room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=True)
player_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=True)
message = Column(Text, nullable=False)
timestamp = Column(DateTime, server_default=func.now())
# Relationships
room = relationship("Room")
player = relationship("User")
class MTGOnlineGame(Base):
"""
Active game session model.
Represents a live game in progress or waiting to start.
Tracks game state, players, and format rules.
"""
__tablename__ = "mtgonline_games"
id = Column(Integer, primary_key=True, index=True)
room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=False, index=True)
host_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
game_type = Column(String(50), nullable=False, default="Commander") # Commander, Standard, Modern, Pauper
state = Column(String(20), nullable=False, default="WAITING") # WAITING, IN_PROGRESS, FINISHED, ABORTED
is_password_protected = Column(Boolean, default=False)
max_players = Column(Integer, default=4)
current_players = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now())
started_at = Column(DateTime, nullable=True)
finished_at = Column(DateTime, nullable=True)
# Relationships
room = relationship("Room")
host = relationship("User", foreign_keys=[host_id])
players = relationship(
"GamePlayer",
back_populates="game",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<MTGOnlineGame {self.id} state={self.state} players={self.current_players}>"
class GamePlayer(Base):
"""
Association table for game participants.
Links users to active games with seat assignments and game state.
"""
__tablename__ = "game_players"
id = Column(Integer, primary_key=True, index=True)
game_id = Column(Integer, ForeignKey("mtgonline_games.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True)
seat_number = Column(Integer, nullable=True) # 1-based seat assignment
life_total = Column(Integer, default=20)
is_ready = Column(Boolean, default=False)
is_host = Column(Boolean, default=False)
joined_at = Column(DateTime, server_default=func.now())
# Relationships
game = relationship("MTGOnlineGame", back_populates="players")
user = relationship("User")
__table_args__ = (
UniqueConstraint("game_id", "user_id", name="uq_game_player_unique"),
Index("idx_game_players_game", "game_id"),
Index("idx_game_players_user", "user_id"),
)
def __repr__(self) -> str:
return f"<GamePlayer game={self.game_id} user={self.user_id} seat={self.seat_number}>"
class GameHistory(Base):
"""
Historical record of completed games.
Stores post-game data including results, participants, and duration.
"""
__tablename__ = "game_history"
id = Column(Integer, primary_key=True, index=True)
game_id = Column(Integer, ForeignKey("mtgonline_games.id"), nullable=True) # Reference to original game
room_id = Column(Integer, ForeignKey("mtgonline_rooms.id"), nullable=True, index=True)
game_type = Column(String(50), nullable=False, default="Commander")
winner_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=True, index=True)
duration_seconds = Column(Integer, nullable=True)
result_type = Column(String(50), nullable=True) # 'victory', 'concession', 'timeout'
replay_data = Column(JSON, nullable=True) # Game event log
created_at = Column(DateTime, server_default=func.now())
# Relationships
room = relationship("Room")
winner = relationship("User", foreign_keys=[winner_id])
def __repr__(self) -> str:
return f"<GameHistory {self.id} type={self.game_type} winner={self.winner_id}>"
class AuditLog(Base):
"""Audit trail for administrative actions."""
__tablename__ = "mtgonline_audit"
id = Column(Integer, primary_key=True, index=True)
admin_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=False)
action_type = Column(String(50), nullable=False) # 'ban', 'unban', 'warn', etc.
target_user_id = Column(Integer, ForeignKey("mtgonline_users.id"), nullable=True)
details = Column(Text, nullable=True)
ip_address = Column(String(45), nullable=True)
timestamp = Column(DateTime, server_default=func.now())
# Relationships
admin = relationship("User", foreign_keys=[admin_id])
target_user = relationship("User", foreign_keys=[target_user_id])
# ---------------------------------------------------------------------------
# Card Mirror (local copy for deckbuilding)
# ---------------------------------------------------------------------------
class MtgonlineCard(Base):
"""
Local card data mirror for the mtgonline database.
Mirrors data from mtg_cards (mtgdata database) to avoid cross-database
joins during deckbuilding. Maintains source_id to reference the canonical
mtg_cards.id for sync tracking.
"""
__tablename__ = "mtgonline_cards"
id = Column(Integer, primary_key=True, index=True)
source_id = Column(Integer, nullable=True, index=True) # References mtg_cards.id in mtgdata
name = Column(String(255), nullable=False, index=True)
mana_cost = Column(String(255), nullable=True)
type_line = Column(String(255), nullable=True)
oracle_text = Column(Text, nullable=True)
power = Column(String(50), nullable=True)
toughness = Column(String(50), nullable=True)
rarity = Column(String(50), nullable=True)
layout = Column(String(50), nullable=True)
artist = Column(String(255), nullable=True)
flavor_text = Column(Text, nullable=True)
numbers = Column(String(100), nullable=True)
identifiers = Column(Text, nullable=True) # JSON string
images = Column(Text, nullable=True) # JSON string
image = Column(Text, nullable=True) # Card image URL
set_code = Column(String(10), nullable=True, index=True)
set_name = Column(String(255), nullable=True)
card_parts = Column(Text, nullable=True) # Comma-separated face names
keywords = Column(Text, nullable=True) # Comma-separated keywords
legalities = Column(Text, nullable=True) # JSON of format legality
# Sync tracking
synced_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
created_at = Column(DateTime, server_default=func.now())
# Relationships
deck_cards = relationship(
"UserDeckCard",
back_populates="card",
cascade="all, delete-orphan",
)
# Indexes
__table_args__ = (
Index("idx_mtgonline_cards_name", "name"),
Index("idx_mtgonline_cards_set", "set_code"),
)
def __repr__(self) -> str:
return f"<MtgonlineCard {self.name} (ID: {self.id}, source: {self.source_id})>"
# ---------------------------------------------------------------------------
# User Deck Models
# ---------------------------------------------------------------------------
class UserDeck(Base):
"""Per-user deck storage in the primary mtgonline database."""
__tablename__ = "user_decks"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False, index=True,
)
name = Column(String(255), nullable=False, index=True)
status = Column(
String(20), nullable=False, default="DRAFT", index=True,
) # DRAFT or FINAL
folder_id = Column(
Integer, ForeignKey("mtgonline_decklist_folders.id"), nullable=True,
)
format = Column(
String(50), nullable=True, default="standard",
) # Standard, Modern, etc.
notes = Column(Text, nullable=True)
is_precedent = Column(Boolean, default=False, index=True) # Mark as deck precedent/template
precedent_name = Column(String(255), nullable=True) # Name for precedent (if applicable)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", backref="user_decks")
folder = relationship("DecklistFolder", backref="user_decks")
cards = relationship(
"UserDeckCard",
back_populates="deck",
cascade="all, delete-orphan",
order_by="UserDeckCard.id",
)
def __repr__(self) -> str:
return f"<UserDeck {self.name} (user={self.user_id}, status={self.status})>"
class UserDeckCard(Base):
"""
Junction table for user deck cards.
Stores individual card entries in a user's deck with quantity and zone.
Card references point to mtg_cards.id in the mtgdata database.
"""
__tablename__ = "user_deck_cards"
id = Column(BigInteger, primary_key=True, autoincrement=True)
deck_id = Column(
BigInteger, ForeignKey("user_decks.id", ondelete="CASCADE"),
nullable=False, index=True,
)
card_id = Column(
Integer, ForeignKey("mtgonline_cards.id"), nullable=False, index=True,
)
quantity = Column(Integer, nullable=False, default=1)
zone = Column(String(20), nullable=False, default="main") # 'main' or 'sideboard'
position = Column(Integer, nullable=True) # Optional ordering within zone
__table_args__ = (
UniqueConstraint("deck_id", "card_id", "zone", name="uq_deck_card_unique"),
Index("idx_deck_cards_deck", "deck_id"),
Index("idx_deck_cards_card", "card_id"),
)
# Relationships
deck = relationship("UserDeck", back_populates="cards")
card = relationship("MtgonlineCard", back_populates="deck_cards")
def __repr__(self) -> str:
return f"<UserDeckCard deck={self.deck_id} card={self.card_id} qty={self.quantity}>"
class DeckPrecedent(Base):
"""
Deck precedent (template) storage.
Precedents are pre-built deck templates that users can clone
to start building their own decks.
"""
__tablename__ = "deck_precedents"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, index=True)
description = Column(Text, nullable=True)
format = Column(String(50), nullable=True, default="standard")
is_public = Column(Boolean, default=True, index=True)
created_by = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=True,
) # None = system
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
creator = relationship("User", foreign_keys=[created_by])
cards = relationship(
"DeckPrecedentCard",
back_populates="precedent",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<DeckPrecedent {self.name} (format={self.format})>"
class DeckPrecedentCard(Base):
"""Junction table for deck precedent cards."""
__tablename__ = "deck_precedent_cards"
id = Column(BigInteger, primary_key=True, autoincrement=True)
precedent_id = Column(
BigInteger, ForeignKey("deck_precedents.id", ondelete="CASCADE"),
nullable=False, index=True,
)
card_id = Column(Integer, nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=1)
zone = Column(String(20), nullable=False, default="main")
__table_args__ = (
UniqueConstraint(
"precedent_id", "card_id", "zone",
name="uq_precedent_card_unique",
),
)
precedent = relationship("DeckPrecedent", back_populates="cards")
def __repr__(self) -> str:
return f"<DeckPrecedentCard precedent={self.precedent_id} card={self.card_id}>"
class CardSuggestion(Base):
"""
Card suggestion storage.
Stores suggested cards for a deck based on similarity,
pairing patterns, or manual curation.
"""
__tablename__ = "card_suggestions"
id = Column(BigInteger, primary_key=True, autoincrement=True)
deck_id = Column(
BigInteger, ForeignKey("user_decks.id", ondelete="CASCADE"),
nullable=False, index=True,
)
card_id = Column(Integer, nullable=False, index=True) # Suggested card
source_card_id = Column(Integer, nullable=True) # Card that triggered the suggestion
suggestion_type = Column(
String(50), nullable=False, default="SIMILAR",
) # SIMILAR, PAIRING, ALTERNATIVE
confidence = Column(Float, nullable=True) # 0.0 to 1.0
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
deck = relationship("UserDeck", backref="suggestions")
__table_args__ = (
UniqueConstraint(
"deck_id", "card_id", "source_card_id",
name="uq_suggestion_unique",
),
)
def __repr__(self) -> str:
return (
f"<CardSuggestion deck={self.deck_id} card={self.card_id} "
f"type={self.suggestion_type}>"
)
# ---------------------------------------------------------------------------
# User Data Models
# ---------------------------------------------------------------------------
class UserSession(Base):
"""User authentication session."""
__tablename__ = "user_sessions"
id = Column(BigInteger, primary_key=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False, index=True,
)
session_token_hash = Column(
String(255), unique=True, nullable=False, index=True,
)
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
expires_at = Column(DateTime, nullable=False)
is_active = Column(Boolean, default=True)
user = relationship("User", backref="sessions")
def __repr__(self) -> str:
return f"<UserSession user={self.user_id} expires={self.expires_at}>"
class DeckVersion(Base):
"""Deck version history."""
__tablename__ = "deck_versions"
id = Column(BigInteger, primary_key=True)
deck_id = Column(
Integer, ForeignKey("mtgonline_decklist_files.id", ondelete="CASCADE"),
nullable=False, index=True,
)
version_number = Column(Integer, nullable=False)
content = Column(Text, nullable=False)
status = Column(String(20), server_default="DRAFT")
comment = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
deck = relationship("DecklistFile", backref="versions")
def __repr__(self) -> str:
return f"<DeckVersion deck={self.deck_id} v={self.version_number}>"
class GameReplay(Base):
"""Game replay recording."""
__tablename__ = "game_replays"
id = Column(BigInteger, primary_key=True)
game_uuid = Column(String(36), unique=True, nullable=False)
room_id = Column(
Integer, ForeignKey("mtgonline_rooms.id"), nullable=True, index=True,
)
game_type = Column(String(50), nullable=True)
format = Column(String(50), nullable=True)
duration_seconds = Column(Integer, nullable=True)
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=True)
status = Column(String(20), server_default="IN_PROGRESS")
replay_data = Column(JSON, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
players = relationship(
"ReplayPlayer", back_populates="replay", cascade="all, delete-orphan",
)
outcomes = relationship(
"GameOutcome", back_populates="replay", cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<GameReplay {self.game_uuid} status={self.status}>"
class ReplayPlayer(Base):
"""Player in a game replay."""
__tablename__ = "replay_players"
id = Column(BigInteger, primary_key=True)
replay_id = Column(
BigInteger, ForeignKey("game_replays.id", ondelete="CASCADE"),
nullable=False, index=True,
)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
position = Column(Integer, nullable=True)
deck_id = Column(
Integer, ForeignKey("mtgonline_decklist_files.id"), nullable=True,
)
won = Column(Boolean, nullable=True)
lost = Column(Boolean, nullable=True)
concession = Column(Boolean, default=False)
turn_one = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())
replay = relationship("GameReplay", back_populates="players")
user = relationship("User")
deck = relationship("DecklistFile")
def __repr__(self) -> str:
return f"<ReplayPlayer replay={self.replay_id} user={self.user_id}>"
class GameOutcome(Base):
"""Game outcome record."""
__tablename__ = "game_outcomes"
id = Column(BigInteger, primary_key=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
game_uuid = Column(
String(36), ForeignKey("game_replays.game_uuid"),
nullable=False, index=True,
)
outcome = Column(String(20), nullable=False, index=True)
opponent_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=True,
)
format = Column(String(50), nullable=True)
rating_before = Column(Integer, nullable=True)
rating_after = Column(Integer, nullable=True)
rating_change = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
replay = relationship("GameReplay", back_populates="outcomes")
user = relationship("User", foreign_keys=[user_id])
opponent = relationship("User", foreign_keys=[opponent_id])
def __repr__(self) -> str:
return f"<GameOutcome user={self.user_id} {self.outcome}>"
class UserStatistics(Base):
"""User game statistics summary."""
__tablename__ = "user_statistics"
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), primary_key=True,
)
total_games = Column(Integer, default=0)
total_wins = Column(Integer, default=0)
total_losses = Column(Integer, default=0)
total_concessions = Column(Integer, default=0)
win_rate = Column(Float, default=0.0)
current_streak = Column(Integer, default=0)
best_streak = Column(Integer, default=0)
average_rating = Column(Float, default=0.0)
last_game_date = Column(DateTime, nullable=True)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
user = relationship("User")
def __repr__(self) -> str:
return (
f"<UserStatistics user={self.user_id} "
f"wins={self.total_wins} losses={self.total_losses}>"
)
class UserCardCollection(Base):
"""User card collection."""
__tablename__ = "user_card_collection"
id = Column(BigInteger, primary_key=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False, index=True,
)
card_id = Column(Integer, nullable=False, index=True)
quantity = Column(Integer, default=1)
condition = Column(String(20), server_default="NEAR_MINT")
language = Column(String(5), server_default="EN")
is_foil = Column(Boolean, default=False)
is_alt_art = Column(Boolean, default=False)
acquired_date = Column(DateTime, server_default=func.now())
acquisition_method = Column(String(50), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint(
"user_id", "card_id", "is_foil", "is_alt_art",
name="uq_collection_unique",
),
Index("idx_collection_user_card", "user_id", "card_id"),
)
user = relationship("User", backref="card_collection")
def __repr__(self) -> str:
return (
f"<UserCard user={self.user_id} card={self.card_id} "
f"qty={self.quantity}>"
)
class CardWishlist(Base):
"""User card wishlist."""
__tablename__ = "card_wishlist"
id = Column(BigInteger, primary_key=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False,
)
card_id = Column(Integer, nullable=False)
max_price = Column(Float, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
UniqueConstraint("user_id", "card_id", name="uq_wishlist_user_card"),
)
user = relationship("User", backref="wishlist")
def __repr__(self) -> str:
return f"<CardWishlist user={self.user_id} card={self.card_id}>"
class UserGroup(Base):
"""User group."""
__tablename__ = "user_groups"
id = Column(BigInteger, primary_key=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
is_public = Column(Boolean, default=True)
max_members = Column(Integer, default=50)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
owner = relationship("User", foreign_keys=[owner_id])
members = relationship(
"GroupMember", back_populates="group", cascade="all, delete-orphan",
)
messages = relationship(
"GroupChatMessage", back_populates="group", cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<UserGroup {self.name} owner={self.owner_id}>"
class GroupMember(Base):
"""Group member."""
__tablename__ = "group_members"
id = Column(BigInteger, primary_key=True)
group_id = Column(
BigInteger, ForeignKey("user_groups.id", ondelete="CASCADE"),
nullable=False, index=True,
)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
role = Column(String(20), server_default="MEMBER")
joined_at = Column(DateTime, server_default=func.now())
group = relationship("UserGroup", back_populates="members")
user = relationship("User")
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_member"),
)
def __repr__(self) -> str:
return (
f"<GroupMember group={self.group_id} user={self.user_id} "
f"role={self.role}>"
)
class GroupChatMessage(Base):
"""Group chat message."""
__tablename__ = "group_chat_messages"
id = Column(BigInteger, primary_key=True)
group_id = Column(
BigInteger, ForeignKey("user_groups.id", ondelete="CASCADE"),
nullable=False, index=True,
)
sender_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
message = Column(Text, nullable=False)
created_at = Column(DateTime, server_default=func.now(), index=True)
group = relationship("UserGroup", back_populates="messages")
sender = relationship("User", foreign_keys=[sender_id])
def __repr__(self) -> str:
return f"<GroupChatMessage group={self.group_id} sender={self.sender_id}>"
class UserNetwork(Base):
"""User network (extended social connection)."""
__tablename__ = "user_networks"
id = Column(BigInteger, primary_key=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
creator_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False,
)
is_public = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
creator = relationship("User", foreign_keys=[creator_id])
members = relationship(
"NetworkMember", back_populates="network", cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<UserNetwork {self.name} creator={self.creator_id}>"
class NetworkMember(Base):
"""Network member."""
__tablename__ = "network_members"
id = Column(BigInteger, primary_key=True)
network_id = Column(
BigInteger, ForeignKey("user_networks.id", ondelete="CASCADE"),
nullable=False, index=True,
)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
role = Column(String(20), server_default="MEMBER")
joined_at = Column(DateTime, server_default=func.now())
network = relationship("UserNetwork", back_populates="members")
user = relationship("User")
__table_args__ = (
UniqueConstraint("network_id", "user_id", name="uq_network_member"),
)
def __repr__(self) -> str:
return (
f"<NetworkMember network={self.network_id} user={self.user_id} "
f"role={self.role}>"
)
class UserPreference(Base):
"""User preferences and settings."""
__tablename__ = "user_preferences"
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), primary_key=True,
)
theme = Column(String(20), server_default="light")
notifications_enabled = Column(Boolean, default=True)
email_notifications = Column(Boolean, default=True)
auto_save_decks = Column(Boolean, default=True)
default_format = Column(String(50), server_default="standard")
language = Column(String(5), server_default="EN")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
user = relationship("User")
def __repr__(self) -> str:
return f"<UserPreference user={self.user_id} theme={self.theme}>"
class UserActivityLog(Base):
"""User activity log."""
__tablename__ = "user_activity_log"
id = Column(BigInteger, primary_key=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id"), nullable=False, index=True,
)
activity_type = Column(String(50), nullable=False, index=True)
activity_data = Column(JSON, nullable=True)
ip_address = Column(String(45), nullable=True)
created_at = Column(DateTime, server_default=func.now(), index=True)
user = relationship("User", backref="activity_logs")
def __repr__(self) -> str:
return f"<UserActivity user={self.user_id} type={self.activity_type}>"
# ---------------------------------------------------------------------------
# Card Import Models
# ---------------------------------------------------------------------------
class CardImportBatch(Base):
"""
Card import batch tracking.
Tracks a batch of card imports from a file, including
matching results and error information.
"""
__tablename__ = "card_import_batches"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False,
)
filename = Column(String(255), nullable=False)
file_type = Column(String(50), nullable=True) # 'mtgjson', 'csv', etc.
file_size = Column(BigInteger, nullable=True) # Size in bytes
status = Column(
String(20), default="PENDING",
) # PENDING, PROCESSING, COMPLETED, FAILED
total_cards = Column(Integer, default=0)
matched_cards = Column(Integer, default=0)
unmatched_cards = Column(Integer, default=0)
match_results = Column(Text, nullable=True) # JSON of match details
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", backref="card_import_batches")
records = relationship(
"UserCardImportRecord",
back_populates="batch",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<CardImportBatch {self.filename} (status={self.status})>"
class UserCardImportRecord(Base):
"""
Individual card import record within a batch.
Tracks whether each card in an import batch was confirmed
and when the confirmation happened.
"""
__tablename__ = "user_card_imports_confirmed"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False,
)
batch_id = Column(
BigInteger, ForeignKey("card_import_batches.id", ondelete="CASCADE"),
nullable=False,
)
is_confirmed = Column(Boolean, default=False)
confirmed_at = Column(DateTime, nullable=True)
# Relationships
batch = relationship("CardImportBatch", back_populates="records")
__table_args__ = (
UniqueConstraint(
"user_id", "batch_id", name="uq_user_card_import_batch",
),
)
def __repr__(self) -> str:
return (
f"<UserCardImportRecord user={self.user_id} batch={self.batch_id} "
f"confirmed={self.is_confirmed}>"
)
class UserCardImport(Base):
"""
User card import record.
Stores a user's imported card collection as a JSON array of card names.
This table was created in migration 004 and may be superseded by
CardImportBatch and UserCardImportRecord in migration 005.
"""
__tablename__ = "user_card_imports"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(
Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"),
nullable=False,
)
card_names_json = Column(Text, nullable=False) # JSON array of card names
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", backref="card_imports")
__table_args__ = (
UniqueConstraint(
"user_id", name="uq_user_card_imports_user_id",
),
)
def __repr__(self) -> str:
return f"<UserCardImport user={self.user_id}>"
# ---------------------------------------------------------------------------
# Cross-Model Relationships
# ---------------------------------------------------------------------------
# Add the back-reference from DecklistFile to DeckCardLink.
# DeckCardLink is defined in mtg_models.py; we import it here after all
# classes in this file are defined to avoid circular imports.
from app.models.mtg_models import DeckCardLink # noqa: E402
DecklistFile.card_links = relationship(
"DeckCardLink",
back_populates="deck",
cascade="all, delete-orphan",
order_by="DeckCardLink.id",
)
# ---------------------------------------------------------------------------
# Indexes for performance
# ---------------------------------------------------------------------------
Index("idx_decks_owner", DecklistFile.owner_id)
Index("idx_decks_folder", DecklistFile.folder_id)
Index("idx_bans_active", Ban.active)
Index("idx_log_timestamp", GameLog.timestamp)