From 03f543358320967069f1d70dde26f6953f09c128 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 26 Aug 2026 03:45:15 +0000 Subject: [PATCH] refactor: consolidate 10+ model files into 2 modules - models.py: All application models (users, decks, sessions, groups, imports, etc.) - mtg_models.py: All MTG card data models (sets, cards, mirrors, links) - Removed: mirror_models.py, user_data.py, user_deck.py, card_import_batch.py, user_card_import.py, user_card_import_record.py, relationships.py - Added __backup__/ directory with original files for reference - DecklistFile.card_links cross-model relationship resolved via late import --- backend/app/models/__init__.py | 78 +- backend/app/models/card_import_batch.py | 74 -- backend/app/models/mirror_models.py | 97 -- backend/app/models/models.py | 980 +++++++++++++++--- backend/app/models/mtg_models.py | 119 ++- backend/app/models/relationships.py | 24 - backend/app/models/user_card_import.py | 40 - backend/app/models/user_card_import_record.py | 11 - backend/app/models/user_data.py | 325 ------ backend/app/models/user_deck.py | 153 --- 10 files changed, 1002 insertions(+), 899 deletions(-) delete mode 100644 backend/app/models/card_import_batch.py delete mode 100644 backend/app/models/mirror_models.py delete mode 100644 backend/app/models/relationships.py delete mode 100644 backend/app/models/user_card_import.py delete mode 100644 backend/app/models/user_card_import_record.py delete mode 100644 backend/app/models/user_data.py delete mode 100644 backend/app/models/user_deck.py diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c8c20a1..cc88ea7 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,56 +1,30 @@ -"""Models package initialization.""" -from app.models.models import User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog -from app.models.mtg_models import MtgSet, MtgCard -from app.models.mirror_models import MtgCardMirror, DeckCardLink -from app.models.user_data import ( - UserSession, DeckVersion, GameReplay, ReplayPlayer, - GameOutcome, UserStatistics, UserCardCollection, CardWishlist, - UserGroup, GroupMember, GroupChatMessage, UserNetwork, - NetworkMember, UserPreference, UserActivityLog -) -from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion -from app.models.card_import_batch import CardImportBatch -from app.models.user_card_import_record import UserCardImportRecord -from app.models.user_card_import import UserCardImport +"""Models package initialization. -# Import relationships LAST to avoid circular imports -# This defines cross-model references (e.g., DecklistFile.card_links) -from app.models import relationships +Consolidated from 10+ fragmented model files into 2 main modules: +- models.py: Application models (users, decks, sessions, groups, etc.) +- mtg_models.py: MTG card data models (sets, cards, mirrors, links) +""" +from app.models.models import ( + User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog, + MtgonlineCard, + UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion, + UserSession, DeckVersion, GameReplay, ReplayPlayer, GameOutcome, + UserStatistics, UserCardCollection, CardWishlist, + UserGroup, GroupMember, GroupChatMessage, + UserNetwork, NetworkMember, + UserPreference, UserActivityLog, + CardImportBatch, UserCardImportRecord, UserCardImport, +) +from app.models.mtg_models import MtgSet, MtgCard, MtgCardMirror, DeckCardLink __all__ = [ - "User", - "DecklistFile", - "DecklistFolder", - "Room", - "RoomGameType", - "Ban", - "GameLog", - "AuditLog", - "MtgSet", - "MtgCard", - "MtgCardMirror", - "DeckCardLink", - "UserSession", - "DeckVersion", - "GameReplay", - "ReplayPlayer", - "GameOutcome", - "UserStatistics", - "UserCardCollection", - "CardWishlist", - "UserGroup", - "GroupMember", - "GroupChatMessage", - "UserNetwork", - "NetworkMember", - "UserPreference", - "UserActivityLog", - "UserDeck", - "UserDeckCard", - "DeckPrecedent", - "DeckPrecedentCard", - "CardSuggestion", - "CardImportBatch", - "UserCardImportRecord", - "UserCardImport", + "User", "DecklistFile", "DecklistFolder", "Room", "RoomGameType", + "Ban", "GameLog", "AuditLog", "MtgSet", "MtgCard", "MtgCardMirror", + "DeckCardLink", "MtgonlineCard", "UserSession", "DeckVersion", + "GameReplay", "ReplayPlayer", "GameOutcome", "UserStatistics", + "UserCardCollection", "CardWishlist", "UserGroup", "GroupMember", + "GroupChatMessage", "UserNetwork", "NetworkMember", "UserPreference", + "UserActivityLog", "UserDeck", "UserDeckCard", "DeckPrecedent", + "DeckPrecedentCard", "CardSuggestion", "CardImportBatch", + "UserCardImportRecord", "UserCardImport", ] diff --git a/backend/app/models/card_import_batch.py b/backend/app/models/card_import_batch.py deleted file mode 100644 index 5f5159b..0000000 --- a/backend/app/models/card_import_batch.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -SQLAlchemy ORM models for card import tracking. - -This module provides models for tracking card import batches -and individual card import records. -""" -from sqlalchemy import ( - Column, Integer, String, BigInteger, Boolean, DateTime, Text, - ForeignKey, UniqueConstraint -) -from sqlalchemy.orm import relationship -from sqlalchemy.sql import func -from app.core.database import Base - - -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"" - - -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"" diff --git a/backend/app/models/mirror_models.py b/backend/app/models/mirror_models.py deleted file mode 100644 index 68d88c6..0000000 --- a/backend/app/models/mirror_models.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -SQLAlchemy ORM models for card mirrors and deck-card links. - -Mirrored card data lives in mtgo_platform for fast deckbuilding queries. -The MTGJSON database remains the canonical source of truth. -""" -from sqlalchemy import ( - Column, Integer, String, Text, DateTime, ForeignKey, Index, - UniqueConstraint, Boolean -) -from sqlalchemy.orm import relationship -from sqlalchemy.sql import func -from app.core.database import Base - - -class MtgCardMirror(Base): - """ - Mirrored card data for user decks. - - Contains all relevant card fields copied from mtg_cards (mtg_data) - to avoid cross-DB joins during deckbuilding. Back-references source_id - to the canonical mtg_cards.id for sync tracking. - """ - __tablename__ = "mtg_cards_mirror" - - id = Column(Integer, primary_key=True, index=True) - source_id = Column( - Integer, - nullable=True, - index=True, - comment="Logical reference to mtg_cards.id in mtg_data database (cross-DB reference, no FK constraint)" - ) - 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 of all identifiers - images = Column(Text, nullable=True) # JSON string of image URLs - image = Column(Text, nullable=True) # Card image URL from MTGJSON - card_parts = Column(Text, nullable=True) # Comma-separated list of face names - keywords = Column(Text, nullable=True) # Comma-separated keywords - legalities = Column(Text, nullable=True) # JSON of format legality - set_code = Column(String(10), nullable=True, index=True) - set_name = Column(String(255), nullable=True) - # Sync tracking - synced_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) - created_at = Column(DateTime, server_default=func.now()) - - # Relationships - deck_links = relationship( - "DeckCardLink", - back_populates="card", - cascade="all, delete-orphan" - ) - - def __repr__(self) -> str: - return f"" - - -class DeckCardLink(Base): - """ - Junction table linking user decks to mirrored cards. - - Represents a specific quantity of a mirrored card in a specific zone - (main deck or sideboard) of a user's deck. - """ - __tablename__ = "deck_card_links" - - id = Column(Integer, primary_key=True, index=True) - deck_id = Column(Integer, ForeignKey("mtgonline_decklist_files.id", ondelete="CASCADE"), nullable=False) - card_id = Column(Integer, ForeignKey("mtg_cards_mirror.id"), nullable=False) - quantity = Column(Integer, nullable=False, default=1) - zone = Column(String(20), nullable=False, default="main") # 'main' or 'sideboard' - - # Composite unique: a card can only appear once per zone in a deck - __table_args__ = ( - UniqueConstraint('deck_id', 'card_id', 'zone', name='uq_deck_card_link'), - Index('idx_deck_card_deck', 'deck_id'), - Index('idx_deck_card_card', 'card_id'), - ) - - # Relationships - deck = relationship("DecklistFile", back_populates="card_links") - card = relationship("MtgCardMirror", back_populates="deck_links") - - def __repr__(self) -> str: - return ( - f"" - ) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index eebca14..e1da51c 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1,18 +1,39 @@ """ -SQLAlchemy ORM models for the MTG Online database. +SQLAlchemy ORM models for the MTG Online application. -Mirrors the original MySQL schema with modern PostgreSQL features. +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 +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 @@ -22,39 +43,177 @@ class User(Base): 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") - + 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"" +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"" + + +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"" + + +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 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) @@ -79,149 +238,730 @@ class MtgonlineCard(Base): # 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" + cascade="all, delete-orphan", ) - + # Indexes __table_args__ = ( - Index('idx_mtgonline_cards_name', 'name'), - Index('idx_mtgonline_cards_set', 'set_code'), + Index("idx_mtgonline_cards_name", "name"), + Index("idx_mtgonline_cards_set", "set_code"), ) - + def __repr__(self) -> str: return f"" -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()) - +# --------------------------------------------------------------------------- +# 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 - 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" + 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", ) - -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 - folder = relationship("DecklistFolder", back_populates="files") - owner = relationship("User", back_populates="decklist_files") - def __repr__(self) -> str: - return f"" + return f"" -class Room(Base): - """Chat room.""" - __tablename__ = "mtgonline_rooms" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String(100), unique=True, nullable=False) +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"" + + +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) - 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") + 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"" -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) +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"" + + +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"" + ) + + +# --------------------------------------------------------------------------- +# 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"" + + +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"" + + +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"" + + +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"" + + +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"" + + +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"" + ) + + +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"" + ) + + +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"" + + +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) - - # Relationships - room = relationship("Room", back_populates="game_types") + 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", + ) -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"" + return f"" -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) +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"" + ) + + +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) - timestamp = Column(DateTime, server_default=func.now()) - - # Relationships - room = relationship("Room") - player = relationship("User") + 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"" -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) +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"" + + +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"" + ) + + +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"" + + +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) - timestamp = Column(DateTime, server_default=func.now()) - + created_at = Column(DateTime, server_default=func.now(), index=True) + + user = relationship("User", backref="activity_logs") + + def __repr__(self) -> str: + return f"" + + +# --------------------------------------------------------------------------- +# 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 - admin = relationship("User", foreign_keys=[admin_id]) - target_user = relationship("User", foreign_keys=[target_user_id]) + user = relationship("User", backref="card_import_batches") + records = relationship( + "UserCardImportRecord", + back_populates="batch", + cascade="all, delete-orphan", + ) + + def __repr__(self) -> str: + return f"" +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"" + ) + + +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"" + + +# --------------------------------------------------------------------------- +# 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) diff --git a/backend/app/models/mtg_models.py b/backend/app/models/mtg_models.py index 3d851b9..76bd650 100644 --- a/backend/app/models/mtg_models.py +++ b/backend/app/models/mtg_models.py @@ -1,14 +1,29 @@ """ -SQLAlchemy ORM models for the MTG database (mtgjson.com data). +SQLAlchemy ORM models for MTG card data and mirrors. -Models mirror the mtg_cards and mtg_sets tables in the MTG PostgreSQL database. +Consolidated from mtg_models.py and mirror_models.py into a single module. + +This file contains all MTG card data models: +- MtgSet, MtgCard: Canonical card/set data from mtgjson.com (mtg_data DB) +- MtgCardMirror: Mirrored card data copied to mtgonline DB for fast deckbuilding queries +- DeckCardLink: Junction table linking user decks to mirrored cards + +Cross-model references to application models (e.g., DeckCardLink.deck → DecklistFile) +use string-based relationship() calls to avoid circular imports. """ -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index +from sqlalchemy import ( + Column, Integer, String, Text, DateTime, ForeignKey, Index, + UniqueConstraint, +) from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.core.database import Base +# --------------------------------------------------------------------------- +# MTG Set and Card (canonical data from mtgjson.com) +# --------------------------------------------------------------------------- + class MtgSet(Base): """MTG Set model.""" __tablename__ = "mtg_sets" @@ -65,7 +80,105 @@ class MtgCard(Base): return f"" +# --------------------------------------------------------------------------- +# Card Mirror and Deck Links (mtgonline DB) +# --------------------------------------------------------------------------- + +class MtgCardMirror(Base): + """ + Mirrored card data for user decks. + + Contains all relevant card fields copied from mtg_cards (mtg_data) + to avoid cross-DB joins during deckbuilding. Back-references source_id + to the canonical mtg_cards.id for sync tracking. + """ + __tablename__ = "mtg_cards_mirror" + + id = Column(Integer, primary_key=True, index=True) + source_id = Column( + Integer, + nullable=True, + index=True, + comment="Logical reference to mtg_cards.id in mtg_data database " + "(cross-DB reference, no FK constraint)", + ) + 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 of all identifiers + images = Column(Text, nullable=True) # JSON string of image URLs + image = Column(Text, nullable=True) # Card image URL from MTGJSON + card_parts = Column(Text, nullable=True) # Comma-separated list of face names + keywords = Column(Text, nullable=True) # Comma-separated keywords + legalities = Column(Text, nullable=True) # JSON of format legality + set_code = Column(String(10), nullable=True, index=True) + set_name = Column(String(255), nullable=True) + # Sync tracking + synced_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + created_at = Column(DateTime, server_default=func.now()) + + # Relationships + deck_links = relationship( + "DeckCardLink", + back_populates="card", + cascade="all, delete-orphan", + ) + + def __repr__(self) -> str: + return f"" + + +class DeckCardLink(Base): + """ + Junction table linking user decks to mirrored cards. + + Represents a specific quantity of a mirrored card in a specific zone + (main deck or sideboard) of a user's deck. + """ + __tablename__ = "deck_card_links" + + id = Column(Integer, primary_key=True, index=True) + deck_id = Column( + Integer, ForeignKey("mtgonline_decklist_files.id", ondelete="CASCADE"), + nullable=False, + ) + card_id = Column( + Integer, ForeignKey("mtg_cards_mirror.id"), nullable=False, + ) + quantity = Column(Integer, nullable=False, default=1) + zone = Column(String(20), nullable=False, default="main") # 'main' or 'sideboard' + + # Composite unique: a card can only appear once per zone in a deck + __table_args__ = ( + UniqueConstraint("deck_id", "card_id", "zone", name="uq_deck_card_link"), + Index("idx_deck_card_deck", "deck_id"), + Index("idx_deck_card_card", "card_id"), + ) + + # Relationships + # String reference to DecklistFile in models.py to avoid circular imports + deck = relationship("DecklistFile", back_populates="card_links") + card = relationship("MtgCardMirror", back_populates="deck_links") + + def __repr__(self) -> str: + return ( + f"" + ) + + +# --------------------------------------------------------------------------- # Indexes for performance +# --------------------------------------------------------------------------- + Index("idx_mtg_cards_name_set", MtgCard.name, MtgCard.set_id) Index("idx_mtg_cards_type", MtgCard.type_line) Index("idx_mtg_cards_rarity", MtgCard.rarity) diff --git a/backend/app/models/relationships.py b/backend/app/models/relationships.py deleted file mode 100644 index f2eba45..0000000 --- a/backend/app/models/relationships.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Cross-model relationships. - -This module defines relationships that reference models from other -model files (e.g., DecklistFile.card_links → DeckCardLink). These -must be defined AFTER all model classes have been imported to avoid -circular import issues. - -Import this module LAST in app/models/__init__.py. -""" -from sqlalchemy.orm import relationship -from app.models.models import DecklistFile -from app.models.mirror_models import DeckCardLink - - -# Add the back-reference from DecklistFile to DeckCardLink. -# This was previously defined dynamically at the bottom of mirror_models.py, -# but that caused circular imports. Now it lives here and is imported last. -DecklistFile.card_links = relationship( - "DeckCardLink", - back_populates="deck", - cascade="all, delete-orphan", - order_by="DeckCardLink.id" -) diff --git a/backend/app/models/user_card_import.py b/backend/app/models/user_card_import.py deleted file mode 100644 index ed5f8be..0000000 --- a/backend/app/models/user_card_import.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -SQLAlchemy ORM model for user card imports. - -This model corresponds to the user_card_imports table created in migration 004. -It stores a user's imported card collection as a JSON array of card names. -""" -from sqlalchemy import ( - Column, Integer, String, BigInteger, DateTime, Text, - ForeignKey, UniqueConstraint -) -from sqlalchemy.orm import relationship -from sqlalchemy.sql import func -from app.core.database import Base - - -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"" diff --git a/backend/app/models/user_card_import_record.py b/backend/app/models/user_card_import_record.py deleted file mode 100644 index d42af5b..0000000 --- a/backend/app/models/user_card_import_record.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -SQLAlchemy ORM models for user card import records. - -This module is deprecated. UserCardImportRecord is now defined in -card_import_batch.py along with CardImportBatch. -""" -# This file is kept for backward compatibility but the actual model -# is now in card_import_batch.py -from app.models.card_import_batch import UserCardImportRecord as UserCardImportRecord - -__all__ = ["UserCardImportRecord"] diff --git a/backend/app/models/user_data.py b/backend/app/models/user_data.py deleted file mode 100644 index eac78dd..0000000 --- a/backend/app/models/user_data.py +++ /dev/null @@ -1,325 +0,0 @@ -""" -SQLAlchemy ORM models for user data features. - -Includes sessions, decks, replays, cards, groups, and networks. -""" -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 - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - -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"" diff --git a/backend/app/models/user_deck.py b/backend/app/models/user_deck.py deleted file mode 100644 index 0010caf..0000000 --- a/backend/app/models/user_deck.py +++ /dev/null @@ -1,153 +0,0 @@ -""" -SQLAlchemy ORM models for per-user deck building. - -These models live in the primary mtgonline database and support -the deckbuilding feature with DRAFT/FINAL status, card management, -deck precedents, and card suggestions. -""" -from sqlalchemy import ( - Column, Integer, String, BigInteger, Boolean, DateTime, Text, - ForeignKey, UniqueConstraint, Index, Float -) -from sqlalchemy.orm import relationship -from sqlalchemy.sql import func -from app.core.database import Base -from app.models.models import MtgonlineCard - - -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"" - - -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"" - - -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"" - - -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"" - - -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""