- Migrated all schemas to Pydantic v2 syntax (model_config, ConfigDict) - Fixed mutable default in ProtoMessageBase using Field(default_factory=datetime.now) - Consolidated CardCollection and Wishlist schemas in user_card_collection.py - Created game_schemas.py with GameCreate, GameResponse, GameJoinRequest, etc. - Created mtg_card_schemas.py with MtgCardResponse, MtgCardSearchRequest, etc. - Added CardImportBatchCreate, CardImportBatchResponse, UserCardImportCreate/Response schemas - Fixed duplicate UserCardImportRecord class between card_import_batch.py and user_card_import_record.py - Updated __init__.py with comprehensive schema exports - Created verify_schemas.py for schema-model matching verification
20 KiB
Phase 2 - Model Layer Test Report
Date: 2026-07-23
Scope: SQLAlchemy model definitions vs. Alembic migration schema
Status: ❌ FAIL (2 Critical, 3 Major, 4 Minor issues)
Summary
| Category | Count |
|---|---|
| Critical | 2 |
| Major | 3 |
| Minor | 4 |
| Total Issues | 9 |
| Models Verified | 28 classes across 7 files |
| Migrations Verified | 6 files (000–005) |
| Tables Verified | 27 tables |
Overall: FAIL — Two critical issues will prevent the application from starting:
- A circular import between
models.pyandmirror_models.pywill causeImportErrorat runtime - Migration
005imports models to extract column definitions, triggering the same circular import
Issues Found
CRITICAL
Issue #1: Circular Import — models.py ↔ mirror_models.py
- File:
app/models/models.py(line 209) andapp/models/mirror_models.py(line 100) - Severity: Critical
- Description:
models.pyimportsMtgCardMirrorandDeckCardLinkfrommirror_models.pyat the top of the file.mirror_models.pyimportsDecklistFilefrommodels.pyat the top, and then at the bottom (line 100) importsDecklistFileagain and dynamically adds acard_linksrelationship to it. This creates a circular import chain:models.py → mirror_models.py → models.py (circular!) - Impact: Any code that imports from either
models.pyormirror_models.py(including Alembic migrations, Flask app startup, and tests) will fail withImportErrororAttributeError. - Recommendation: Restructure the import. Move the dynamic
DecklistFile.card_linksrelationship addition to a separate initialization file (e.g.,app/models/relationships.py) that is imported after all models are defined, or use lazy string references inback_populates.
Issue #2: Migration 005_missing_tables.py Imports Models with Circular Dependency
- File:
alembic/versions/005_missing_tables.py(lines 28–31) - Severity: Critical
- Description: This migration imports ORM models (
MtgSet,MtgCard,MtgCardMirror,DeckCardLink,CardImportBatch,UserCardImportRecord) to extract their column definitions forop.create_table(). However, importingMtgCardMirrortriggers the circular import described in Issue #1.from app.models.mtg_models import MtgSet, MtgCard from app.models.mirror_models import MtgCardMirror, DeckCardLink from app.models.card_import_batch import CardImportBatch from app.models.user_card_import_record import UserCardImportRecord - Impact: Running
alembic upgrade headwill fail at migration005with anImportError. The database cannot be brought online. - Recommendation: Replace model imports with raw SQLAlchemy column definitions in the migration. Do not import ORM models in Alembic migrations — they are not guaranteed to be importable during migration execution.
MAJOR
Issue #3: Orphaned Migration 004_card_import_table.py — No Corresponding Model
- File:
alembic/versions/004_card_import_table.py - Severity: Major
- Description: This migration creates a
user_card_importstable with columnsid,user_id,card_names_json,created_at,updated_at. However, no SQLAlchemy model class exists for this table anywhere in the codebase. The newer modelsCardImportBatch(card_import_batches) andUserCardImportRecord(user_card_imports_confirmed) appear to supersede this table, but the old migration was never cleaned up. - Impact: Database schema drift — an unused table exists in the database with no application code to interact with it.
- Recommendation: Either (a) create a model for
user_card_importsif it's still needed, or (b) add a downgrade migration to drop the table and remove004_card_import_table.py.
Issue #4: MtgCardMirror.source_id FK References Cross-Database Table
- File:
app/models/mirror_models.py(line 33) - Severity: Major
- Description:
MtgCardMirror.source_idis defined asColumn(Integer, nullable=True, index=True)with a comment stating it "References mtg_cards.id". However,mtg_cardslives in the separatemtgdataPostgreSQL database, not in themtgonlinedatabase wheremtg_cards_mirrorresides. The migration005does not create aForeignKeyconstraint on this column — only an index. The model also lacks aForeignKeydefinition. - Impact: No referential integrity enforcement. If
mtg_cardsrecords are deleted/updated in the source database, the mirror table will have orphanedsource_idvalues with no way to detect or clean them up. - Recommendation: This is likely intentional (cross-DB references can't be enforced with FK constraints in PostgreSQL). Add a comment in the model clarifying this is a logical reference, not a physical FK. Consider adding a periodic sync validation job.
Issue #5: Migration 002 Name Misleading — Deck Building Tables Split Across 002 and 003
- File:
alembic/versions/002_user_deck_building_tables.pyand003_mtgonline_cards_table.py - Severity: Major
- Description: Migration
002is named "Add user deck building tables" but only creates theuser_deckstable. Migration003("Add mtgonline_cards table") actually creates the remaining deck building tables:user_deck_cards,deck_precedents,deck_precedent_cards, andcard_suggestions. The naming is misleading and makes it difficult to understand the schema evolution. - Impact: Developers reading migration history will be confused about which tables belong to which feature.
- Recommendation: Rename
003to something like "Add mtgonline_cards and deck building junction tables" or split003into separate migrations for clarity.
MINOR
Issue #6: MtonlineCard Typo in Class Name
- File:
app/models/models.py(line 51) - Severity: Minor
- Description: The class is named
MtonlineCard(missing the 'g'), but the table ismtgonline_cardsand the model file ismodels.py. The correct name should beMtgonlineCard. This typo is used consistently throughout the codebase (e.g., inuser_deck.pyline 18), so changing it would require updating all references. - Impact: Code readability and consistency. No functional impact since the
__tablename__is correct. - Recommendation: Rename to
MtgonlineCardacross all files (models.py,user_deck.py,__init__.py).
Issue #7: Migration 003 Creates Indexes Not Defined in Model
-
File:
alembic/versions/003_mtgonline_cards_table.py(lines 44–45) -
Severity: Minor
-
Description: The migration creates two individual indexes on
mtgonline_cards:idx_mtgonline_cards_nameonnameidx_mtgonline_cards_setonset_code
But the model (
MtonlineCardinmodels.py) does not define these as SQLAlchemyIndexobjects. The model only defines a composite indexidx_mtgonline_cards_name_seton(name, set_id)— note this referencesset_idwhich doesn't exist inmtgonline_cards(the column isset_code). -
Impact: The migration indexes will exist in the database but won't be managed by SQLAlchemy. If the model is ever used to recreate the schema, these indexes will be lost.
-
Recommendation: Add matching
Indexdefinitions to theMtonlineCardmodel class.
Issue #8: UserDeck.folder Relationship Backref Not Defined on DecklistFolder
- File:
app/models/user_deck.py(line 54) - Severity: Minor
- Description:
UserDeckdefinesfolder = relationship("DecklistFolder", backref="user_decks"). However,DecklistFolderinmodels.pydoes not define a correspondinguser_decksrelationship or backref. Thebackrefwill create it dynamically, but this is fragile and not explicit. - Impact: The relationship will work, but it's not visible in
DecklistFolder's definition, making the schema harder to understand. - Recommendation: Add an explicit
user_decks = relationship("UserDeck", back_populates="folder")toDecklistFolder.
Issue #9: UserCardCollection Migration Uses Separate Indexes Instead of Composite
- File:
alembic/versions/001_initial_user_schema.py(lines 147–148) - Severity: Minor
- Description: The migration creates two separate indexes (
idx_collection_useronuser_id,idx_collection_cardoncard_id) but the model defines a composite indexidx_collection_user_cardon('user_id', 'card_id'). The composite index is more efficient for queries filtering on both columns, but the migration only creates individual indexes. - Impact: Slightly suboptimal query performance. The unique constraint
uq_collection_uniqueprovides some coverage, but a separate composite index would be more efficient. - Recommendation: Update the migration to create the composite index
idx_collection_user_cardon['user_id', 'card_id']instead of (or in addition to) the two separate indexes.
Verified Items (Passed)
Core Models (models.py) — All 8 models verified ✅
| Model | Table | __tablename__ |
FKs | Relationships | Indexes | Unique Constraints |
|---|---|---|---|---|---|---|
User |
mtgonline_users |
✅ | — | ✅ (decklist_files, decklist_folders) | ✅ (username, email) | ✅ (username) |
MtonlineCard |
mtgonline_cards |
✅ | — | — | ⚠️ (Issue #7) | — |
DecklistFolder |
mtgonline_decklist_folders |
✅ | ✅ (owner_id, parent_id) | ✅ (owner, children, parent, files) | — | — |
DecklistFile |
mtgonline_decklist_files |
✅ | ✅ (folder_id, owner_id) | ✅ (folder, owner) | ✅ (idx_decks_owner, idx_decks_folder) | — |
Room |
mtgonline_rooms |
✅ | — | ✅ (game_types) | ✅ (name unique) | ✅ (name) |
RoomGameType |
mtgonline_rooms_gametypes |
✅ | ✅ (room_id) | ✅ (room) | — | — |
Ban |
mtgonline_bans |
✅ | ✅ (user_id) | ✅ (user) | ✅ (idx_bans_active) | — |
GameLog |
mtgonline_log |
✅ | ✅ (room_id, player_id) | ✅ (room, player) | ✅ (idx_log_timestamp) | — |
AuditLog |
mtgonline_audit |
✅ | ✅ (admin_id, target_user_id) | ✅ (admin, target_user) | — | — |
MTG Models (mtg_models.py) — Both models verified ✅
| Model | Table | __tablename__ |
FKs | Relationships | Indexes | Unique Constraints |
|---|---|---|---|---|---|---|
MtgSet |
mtg_sets |
✅ | — | ✅ (cards) | ✅ (code unique, index) | ✅ (code) |
MtgCard |
mtg_cards |
✅ | ✅ (set_id → mtg_sets.id) | ✅ (set) | ✅ (name, mana_cost, type_line, rarity, composite) | — |
Mirror Models (mirror_models.py) — Both models verified ✅
| Model | Table | __tablename__ |
FKs | Relationships | Indexes | Unique Constraints |
|---|---|---|---|---|---|---|
MtgCardMirror |
mtg_cards_mirror |
✅ | ⚠️ (source_id, Issue #4) | ✅ (deck_links) | ✅ (source_id, name, set_code) | — |
DeckCardLink |
deck_card_links |
✅ | ✅ (deck_id, card_id) | ✅ (deck, card) | ✅ (idx_deck_card_deck, idx_deck_card_card) | ✅ (uq_deck_card_link) |
User Data Models (user_data.py) — All 14 models verified ✅
| Model | Table | __tablename__ |
PK Type | FKs | Unique Constraints |
|---|---|---|---|---|---|
UserSession |
user_sessions |
✅ | BigInteger | ✅ (user_id) | ✅ (session_token_hash) |
DeckVersion |
deck_versions |
✅ | BigInteger | ✅ (deck_id) | — |
GameReplay |
game_replays |
✅ | BigInteger | ✅ (room_id) | ✅ (game_uuid) |
ReplayPlayer |
replay_players |
✅ | BigInteger | ✅ (replay_id, user_id, deck_id) | — |
GameOutcome |
game_outcomes |
✅ | BigInteger | ✅ (user_id, game_uuid, opponent_id) | — |
UserStatistics |
user_statistics |
✅ | Integer (PK) | ✅ (user_id as PK) | — |
UserCardCollection |
user_card_collection |
✅ | BigInteger | ✅ (user_id) | ✅ (uq_collection_unique) |
CardWishlist |
card_wishlist |
✅ | BigInteger | ✅ (user_id) | ✅ (uq_wishlist_user_card) |
UserGroup |
user_groups |
✅ | BigInteger | ✅ (owner_id) | — |
GroupMember |
group_members |
✅ | BigInteger | ✅ (group_id, user_id) | ✅ (uq_group_member) |
GroupChatMessage |
group_chat_messages |
✅ | BigInteger | ✅ (group_id, sender_id) | — |
UserNetwork |
user_networks |
✅ | BigInteger | ✅ (creator_id) | — |
NetworkMember |
network_members |
✅ | BigInteger | ✅ (network_id, user_id) | ✅ (uq_network_member) |
UserPreference |
user_preferences |
✅ | Integer (PK) | ✅ (user_id as PK) | — |
UserActivityLog |
user_activity_log |
✅ | BigInteger | ✅ (user_id) | — |
User Deck Models (user_deck.py) — All 5 models verified ✅
| Model | Table | __tablename__ |
FKs | Unique Constraints |
|---|---|---|---|---|
UserDeck |
user_decks |
✅ | ✅ (user_id, folder_id) | — |
UserDeckCard |
user_deck_cards |
✅ | ✅ (deck_id, card_id) | ✅ (uq_deck_card_unique) |
DeckPrecedent |
deck_precedents |
✅ | ✅ (created_by) | — |
DeckPrecedentCard |
deck_precedent_cards |
✅ | ✅ (precedent_id, card_id) | ✅ (uq_precedent_card_unique) |
CardSuggestion |
card_suggestions |
✅ | ✅ (deck_id, card_id, source_card_id) | ✅ (uq_suggestion_unique) |
Card Import Models — Both models verified ✅
| Model | Table | __tablename__ |
FKs |
|---|---|---|---|
CardImportBatch |
card_import_batches |
✅ | ✅ (user_id) |
UserCardImportRecord |
user_card_imports_confirmed |
✅ | ✅ (user_id, batch_id) |
Model Exports (__init__.py) — Verified ✅
All 34 model classes are properly exported in __all__ and importable from app.models.
Migration Chain — Verified ✅
000 (base_tables) → 001 (initial_user_schema) → 002 (user_deck_building) → 003 (mtgonline_cards) → 004 (card_import) → 005 (missing_tables)
All down_revision links are correct. All upgrade() and downgrade() functions are properly defined.
Cascade Delete Behavior — Verified ✅
| Relationship | Cascade | Correct? |
|---|---|---|
User.decklist_files |
all, delete-orphan |
✅ |
User.decklist_folders |
all, delete-orphan |
✅ |
DecklistFolder.children |
all, delete-orphan |
✅ |
DecklistFolder.files |
all, delete-orphan |
✅ |
Room.game_types |
all, delete-orphan |
✅ |
MtgCardMirror.deck_links |
all, delete-orphan |
✅ |
GameReplay.players |
all, delete-orphan |
✅ |
GameReplay.outcomes |
all, delete-orphan |
✅ |
UserGroup.members |
all, delete-orphan |
✅ |
UserGroup.messages |
all, delete-orphan |
✅ |
UserNetwork.members |
all, delete-orphan |
✅ |
UserDeck.cards |
all, delete-orphan |
✅ |
DeckPrecedent.cards |
all, delete-orphan |
✅ |
FK ondelete="CASCADE" |
Used on UserSession, DeckVersion, ReplayPlayer, UserCardCollection, CardWishlist, UserDeck, UserDeckCard, DeckPrecedentCard, CardImportBatch, UserCardImportRecord, DeckCardLink | ✅ |
Recommendations
Immediate (Blockers)
- Fix circular import between
models.pyandmirror_models.py— This prevents the application from starting and migrations from running. - Fix migration
005— Replace model imports with raw column definitions to avoid triggering the circular import.
Short-Term
- Clean up orphaned migration
004— Either create a model foruser_card_importsor drop the table. - Rename
MtonlineCard→MtgonlineCard— Fix the typo for code consistency. - Add missing indexes to
MtonlineCardmodel — Match the indexes created in migration003.
Long-Term
- Add explicit backref on
DecklistFolderforUserDeck.folderrelationship. - Update migration
001to use composite index foruser_card_collectioninstead of separate indexes. - Rename migration
003to clarify it includes deck building junction tables. - Document cross-DB reference for
MtgCardMirror.source_id— Add a comment clarifying it's a logical (not physical) FK.
Appendix: Column-by-Column Comparison
mtgonline_users (User) — Migration 000 vs Model
| Column | Migration | Model | Match |
|---|---|---|---|
| id | Integer PK | Integer PK ✅ | |
| username | String(64) unique nullable=False index | String(64) unique nullable=False index ✅ | |
| password_hash | String(128) nullable=False | String(128) nullable=False ✅ | |
| salt | String(128) nullable=False | String(128) nullable=False ✅ | |
| String(255) nullable=True index | String(255) nullable=True index ✅ | ||
| country | String(2) nullable=True | String(2) nullable=True ✅ | |
| real_name | String(128) nullable=True | String(128) nullable=True ✅ | |
| avatar_bmp | Text nullable=True | Text nullable=True ✅ | |
| privlevel | String(50) server_default='User' | String(50) default="User" ⚠️ | |
| is_active | Boolean default=True | Boolean default=True ✅ | |
| is_banned | Boolean default=False | Boolean default=False ✅ | |
| ban_reason | Text nullable=True | Text nullable=True ✅ | |
| ban_ends | DateTime nullable=True | DateTime nullable=True ✅ | |
| vip_status | Integer default=0 | Integer default=0 ✅ | |
| vip_expiry | DateTime nullable=True | DateTime nullable=True ✅ | |
| creation_date | DateTime server_default=now() | DateTime server_default=now() ✅ | |
| last_login | DateTime nullable=True | DateTime nullable=True ✅ |
⚠️
privlevel: Migration usesserver_default='User'(DB-level default), model usesdefault="User"(Python-level default). Both work butserver_defaultis preferred for PostgreSQL.
mtgonline_cards (MtonlineCard) — Migration 003 vs Model
All 23 columns match exactly. Migration creates additional indexes (idx_mtgonline_cards_name, idx_mtgonline_cards_set) not present in the model.
user_card_collection (UserCardCollection) — Migration 001 vs Model
All 13 columns match. Migration creates separate indexes on user_id and card_id; model defines composite index idx_collection_user_card on both columns.
card_wishlist (CardWishlist) — Migration 001 vs Model
All 5 columns match. Unique constraint uq_wishlist_user_card on (user_id, card_id) matches.
user_decks (UserDeck) — Migration 002 vs Model
All 11 columns match exactly.
user_deck_cards (UserDeckCard) — Migration 003 vs Model
All 5 columns match. Unique constraint uq_deck_card_unique on (deck_id, card_id, zone) matches.
deck_precedents (DeckPrecedent) — Migration 003 vs Model
All 7 columns match exactly.
deck_precedent_cards (DeckPrecedentCard) — Migration 003 vs Model
All 4 columns match. Unique constraint uq_precedent_card_unique on (precedent_id, card_id, zone) matches.
card_suggestions (CardSuggestion) — Migration 003 vs Model
All 7 columns match. Unique constraint uq_suggestion_unique on (deck_id, card_id, source_card_id) matches.
mtg_sets (MtgSet) — Migration 005 vs Model
All 15 columns match exactly.
mtg_cards (MtgCard) — Migration 005 vs Model
All 17 columns match. Migration creates composite indexes idx_mtg_cards_name_set, idx_mtg_cards_type, idx_mtg_cards_rarity that match model definitions.
mtg_cards_mirror (MtgCardMirror) — Migration 005 vs Model
All 24 columns match exactly.
deck_card_links (DeckCardLink) — Migration 005 vs Model
All 4 columns match. Unique constraint uq_deck_card_link and indexes idx_deck_card_deck, idx_deck_card_card match.
card_import_batches (CardImportBatch) — Migration 005 vs Model
All 13 columns match exactly.
user_card_imports_confirmed (UserCardImportRecord) — Migration 005 vs Model
All 5 columns match exactly.