Phase 2.1: Card mirror system for deckbuilding features

- Created MtgCardMirror and DeckCardLink models in mirror_models.py
- Created card_mirror_service.py with sync_mirrors functionality
- Added sync_mirrors() method to mtgjson_manager.py
- Added mirror_get_db() dependency to database.py
- Added DecklistFile.status column (DRAUGHT/FINAL)
- Updated DeckCreate schema with status field
- Added DeckWithCardsResponse schema with card_count
- Updated deck router to query card mirrors and return card counts
- Added plain text deck content support
This commit is contained in:
2026-07-22 03:35:57 +00:00
parent a01e33eb5e
commit 2c74a107bc
9 changed files with 744 additions and 70 deletions
+35 -7
View File
@@ -40,13 +40,9 @@ mtg_async_session = async_sessionmaker(
expire_on_commit=False,
)
class Base(DeclarativeBase):
"""Base class for all ORM models."""
pass
__all__ = ["Base", "get_db", "async_session", "engine", "mtg_get_db", "mtg_async_session", "mtg_engine"]
# Mirror engine (same as primary — mirrors live in mtgo_platform)
mirror_engine = engine
mirror_async_session = async_session
async def get_db() -> AsyncSession:
@@ -73,3 +69,35 @@ async def mtg_get_db() -> AsyncSession:
raise
finally:
await session.close()
async def mirror_get_db() -> AsyncSession:
"""FastAPI dependency that provides a database session for mirror operations."""
async with mirror_async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
class Base(DeclarativeBase):
"""Base class for all ORM models."""
pass
__all__ = [
"Base",
"get_db",
"async_session",
"engine",
"mtg_get_db",
"mtg_async_session",
"mtg_engine",
"mirror_get_db",
"mirror_async_session",
"mirror_engine",
]
+19 -1
View File
@@ -1 +1,19 @@
# Models package
# Models package
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
__all__ = [
"User",
"DecklistFile",
"DecklistFolder",
"Room",
"RoomGameType",
"Ban",
"GameLog",
"AuditLog",
"MtgSet",
"MtgCard",
"MtgCardMirror",
"DeckCardLink",
]
+103
View File
@@ -0,0 +1,103 @@
"""
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) # References mtg_cards.id
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"<MtgCardMirror {self.name} (ID: {self.id}, source: {self.source_id})>"
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"<DeckCardLink deck={self.deck_id} card={self.card_id} "
f"qty={self.quantity} zone={self.zone}>"
)
# Add back-references to existing models
from app.models.models import DecklistFile
DecklistFile.card_links = relationship(
"DeckCardLink",
back_populates="deck",
cascade="all, delete-orphan",
order_by="DeckCardLink.id"
)
+1
View File
@@ -72,6 +72,7 @@ class DecklistFile(Base):
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
+47 -10
View File
@@ -1,18 +1,19 @@
"""Deck management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, update
from sqlalchemy import select, delete, update, func
from typing import Optional, List
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import DecklistFile, DecklistFolder
from app.schemas.schemas import DeckCreate, DeckUpdate, DeckResponse, FolderCreate, FolderResponse
from app.models.mirror_models import DeckCardLink
from app.schemas.schemas import DeckCreate, DeckUpdate, DeckResponse, DeckWithCardsResponse, FolderCreate, FolderResponse
router = APIRouter()
@router.get("/", response_model=List[DeckResponse])
@router.get("/", response_model=List[DeckWithCardsResponse])
async def list_decks(
folder_id: Optional[int] = None,
page: int = 1,
@@ -44,10 +45,24 @@ async def list_decks(
result = await db.execute(stmt)
decks = result.scalars().all()
return [DeckResponse.model_validate(deck) for deck in decks]
# Include card counts for each deck
deck_responses = []
for deck in decks:
# Get card count
count_stmt = select(func.count()).select_from(DeckCardLink).where(
DeckCardLink.deck_id == deck.id
)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
deck_data = DeckWithCardsResponse.model_validate(deck)
deck_data.card_count = card_count
deck_responses.append(deck_data)
return deck_responses
@router.post("/", response_model=DeckResponse)
@router.post("/", response_model=DeckWithCardsResponse)
async def create_deck(
request: DeckCreate,
db: AsyncSession = Depends(get_db),
@@ -76,11 +91,15 @@ async def create_deck(
name=request.name,
content=request.content,
format=request.format,
status=request.status,
)
db.add(new_deck)
await db.flush()
return DeckResponse.model_validate(new_deck)
# Return with card count (0 for new deck)
result = DeckWithCardsResponse.model_validate(new_deck)
result.card_count = 0
return result
@router.get("/folders", response_model=List[FolderResponse])
@@ -171,7 +190,7 @@ async def delete_folder(
return {"message": "Folder deleted successfully"}
@router.get("/{deck_id}", response_model=DeckResponse)
@router.get("/{deck_id}", response_model=DeckWithCardsResponse)
async def get_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
@@ -193,10 +212,19 @@ async def get_deck(
detail="Deck not found",
)
return DeckResponse.model_validate(deck)
# Get card count
count_stmt = select(func.count()).select_from(DeckCardLink).where(
DeckCardLink.deck_id == deck_id
)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
result = DeckWithCardsResponse.model_validate(deck)
result.card_count = card_count
return result
@router.patch("/{deck_id}", response_model=DeckResponse)
@router.patch("/{deck_id}", response_model=DeckWithCardsResponse)
async def update_deck(
deck_id: int,
request: DeckUpdate,
@@ -235,7 +263,16 @@ async def update_deck(
result = await db.execute(stmt)
updated_deck = result.scalar_one_or_none()
return DeckResponse.model_validate(updated_deck)
# Get card count
count_stmt = select(func.count()).select_from(DeckCardLink).where(
DeckCardLink.deck_id == deck_id
)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
result = DeckWithCardsResponse.model_validate(updated_deck)
result.card_count = card_count
return result
@router.delete("/{deck_id}")
+65 -1
View File
@@ -4,7 +4,7 @@ Pydantic schemas for request/response validation.
Provides typed data structures for API endpoints.
"""
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, List
from typing import Optional, List, Dict
from datetime import datetime
@@ -95,6 +95,7 @@ class DeckCreate(BaseModel):
content: str = Field(..., min_length=1)
folder_id: Optional[int] = None
format: str = Field("native", pattern="^(native|plain)$")
status: str = Field("DRAUGHT", pattern="^(DRAUGHT|FINAL)$")
class DeckUpdate(BaseModel):
@@ -102,6 +103,7 @@ class DeckUpdate(BaseModel):
name: Optional[str] = None
content: Optional[str] = None
folder_id: Optional[int] = None
status: Optional[str] = Field(None, pattern="^(DRAUGHT|FINAL)$")
class DeckResponse(BaseModel):
@@ -110,6 +112,7 @@ class DeckResponse(BaseModel):
name: str
content: str
format: str
status: str
folder_id: Optional[int]
owner_id: int
creation_date: datetime
@@ -228,3 +231,64 @@ class PaginatedResponse(BaseModel):
page: int
page_size: int
total_pages: int
# ===== Card Mirror Schemas =====
class CardMirrorResponse(BaseModel):
"""Mirrored card data for user decks."""
id: int
source_id: Optional[int]
name: str
mana_cost: Optional[str]
type_line: Optional[str]
oracle_text: Optional[str]
power: Optional[str]
toughness: Optional[str]
rarity: Optional[str]
layout: Optional[str]
artist: Optional[str]
flavor_text: Optional[str]
numbers: Optional[str]
identifiers: Optional[str] # JSON string
images: Optional[str] # JSON string
image: Optional[str]
card_parts: Optional[str]
keywords: Optional[str]
legalities: Optional[str] # JSON string
set_code: Optional[str]
set_name: Optional[str]
synced_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class DeckCardLinkResponse(BaseModel):
"""Deck-card link response."""
id: int
deck_id: int
card_id: int
quantity: int
zone: str
card: CardMirrorResponse
class Config:
from_attributes = True
class DeckWithCardsResponse(BaseModel):
"""Deck response with card links."""
id: int
name: str
content: str
format: str
status: str
folder_id: Optional[int]
owner_id: int
creation_date: datetime
card_links: List[DeckCardLinkResponse] = []
class Config:
from_attributes = True
+359
View File
@@ -0,0 +1,359 @@
"""
Card Mirror Service
Manages mirrored card data in mtgo_platform for fast deckbuilding queries.
Syncs with mtg_cards (mtg_data) when the card database is refreshed.
"""
from typing import List, Dict, Any, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update, delete
from sqlalchemy.orm import selectinload
from app.models.mtg_models import MtgCard, MtgSet
from app.models.mirror_models import MtgCardMirror, DeckCardLink
from app.core.database import mirror_get_db
async def upsert_card_mirror(
db: AsyncSession,
card_data: Dict[str, Any],
source_id: Optional[int] = None
) -> MtgCardMirror:
"""
Upsert a card into the mirror table.
Args:
db: Database session
card_data: Card data dictionary
source_id: Optional reference to mtg_cards.id
Returns:
The upserted MtgCardMirror instance
"""
# Check if card already exists by name
existing = await db.execute(
select(MtgCardMirror).where(MtgCardMirror.name == card_data.get("name"))
)
existing_card = existing.scalar_one_or_none()
if existing_card:
# Update existing mirror
for key, value in card_data.items():
if hasattr(existing_card, key):
setattr(existing_card, key, value)
if source_id:
existing_card.source_id = source_id
else:
# Create new mirror
mirror_data = {
"name": card_data.get("name"),
"mana_cost": card_data.get("mana_cost"),
"type_line": card_data.get("type_line"),
"oracle_text": card_data.get("oracle_text"),
"power": card_data.get("power"),
"toughness": card_data.get("toughness"),
"rarity": card_data.get("rarity"),
"layout": card_data.get("layout"),
"artist": card_data.get("artist"),
"flavor_text": card_data.get("flavor_text"),
"numbers": card_data.get("numbers"),
"identifiers": card_data.get("identifiers"),
"images": card_data.get("images"),
"image": card_data.get("image"),
"card_parts": card_data.get("card_parts"),
"keywords": card_data.get("keywords"),
"legalities": card_data.get("legalities"),
"set_code": card_data.get("set_code"),
"set_name": card_data.get("set_name"),
"source_id": source_id,
}
mirror_card = MtgCardMirror(**mirror_data)
db.add(mirror_card)
await db.flush()
return mirror_card
return existing_card
async def get_card_mirror_by_name(
db: AsyncSession,
name: str
) -> Optional[MtgCardMirror]:
"""
Get a mirrored card by name.
Args:
db: Database session
name: Card name
Returns:
MtgCardMirror instance or None
"""
result = await db.execute(
select(MtgCardMirror).where(MtgCardMirror.name == name)
)
return result.scalar_one_or_none()
async def search_card_mirrors(
db: AsyncSession,
query: str,
limit: int = 100,
offset: int = 0
) -> Tuple[List[MtgCardMirror], int]:
"""
Search mirrored cards by name.
Args:
db: Database session
query: Search query
limit: Maximum results
offset: Pagination offset
Returns:
Tuple of (list of mirrors, total count)
"""
search_term = f"%{query.lower()}%"
# Search query
stmt = (
select(MtgCardMirror)
.where(MtgCardMirror.name.ilike(search_term))
.offset(offset)
.limit(limit)
)
result = await db.execute(stmt)
mirrors = result.scalars().all()
# Total count
count_stmt = select(func.count()).select_from(MtgCardMirror).where(
MtgCardMirror.name.ilike(search_term)
)
count_result = await db.execute(count_stmt)
total = count_result.scalar()
return mirrors, total
async def add_card_to_deck(
db: AsyncSession,
deck_id: int,
card_id: int,
quantity: int = 1,
zone: str = "main"
) -> DeckCardLink:
"""
Add a card to a deck.
Args:
db: Database session
deck_id: Deck ID
card_id: Mirrored card ID
quantity: Number of copies
zone: 'main' or 'sideboard'
Returns:
DeckCardLink instance
"""
# Check if link already exists
existing = await db.execute(
select(DeckCardLink).where(
DeckCardLink.deck_id == deck_id,
DeckCardLink.card_id == card_id,
DeckCardLink.zone == zone
)
)
existing_link = existing.scalar_one_or_none()
if existing_link:
# Update quantity
existing_link.quantity = quantity
await db.flush()
return existing_link
else:
# Create new link
link = DeckCardLink(
deck_id=deck_id,
card_id=card_id,
quantity=quantity,
zone=zone
)
db.add(link)
await db.flush()
return link
async def remove_card_from_deck(
db: AsyncSession,
deck_id: int,
card_id: int,
zone: str = "main"
) -> bool:
"""
Remove a card from a deck.
Args:
db: Database session
deck_id: Deck ID
card_id: Mirrored card ID
zone: 'main' or 'sideboard'
Returns:
True if removed, False if not found
"""
result = await db.execute(
delete(DeckCardLink).where(
DeckCardLink.deck_id == deck_id,
DeckCardLink.card_id == card_id,
DeckCardLink.zone == zone
)
)
return result.rowcount > 0
async def get_deck_cards(
db: AsyncSession,
deck_id: int
) -> List[DeckCardLink]:
"""
Get all cards in a deck with their mirrored data.
Args:
db: Database session
deck_id: Deck ID
Returns:
List of DeckCardLink instances with joined card data
"""
stmt = (
select(DeckCardLink, MtgCardMirror)
.join(MtgCardMirror, DeckCardLink.card_id == MtgCardMirror.id)
.where(DeckCardLink.deck_id == deck_id)
.order_by(DeckCardLink.id)
)
result = await db.execute(stmt)
rows = result.all()
links = []
for link, card in rows:
link.card = card
links.append(link)
return links
async def get_user_deck_summaries(
db: AsyncSession,
user_id: int
) -> List[Dict[str, Any]]:
"""
Get all decks for a user with card counts.
Args:
db: Database session
user_id: User ID
Returns:
List of deck summaries with card counts
"""
from app.models.models import DecklistFile
stmt = (
select(DecklistFile)
.where(DecklistFile.owner_id == user_id)
.order_by(DecklistFile.creation_date.desc())
)
result = await db.execute(stmt)
decks = result.scalars().all()
summaries = []
for deck in decks:
# Get card count
count_stmt = (
select(func.count())
.select_from(DeckCardLink)
.where(DeckCardLink.deck_id == deck.id)
)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar()
summaries.append({
"id": deck.id,
"name": deck.name,
"format": deck.format,
"status": deck.status,
"folder_id": deck.folder_id,
"owner_id": deck.owner_id,
"creation_date": deck.creation_date,
"card_count": card_count,
})
return summaries
async def sync_mirrors_from_mtg_cards(
db: AsyncSession,
mtg_db: Optional[AsyncSession] = None
) -> int:
"""
Sync all mirrored cards from the mtg_cards table.
This is called when the card database is refreshed.
Args:
db: Mirror database session
mtg_db: Optional MTG database session for cross-DB queries
Returns:
Number of cards synced
"""
if not mtg_db:
# Use the same session if no MTG session provided
# Note: In production, you'd need a cross-DB connection
# For now, we'll just refresh the mirror from existing data
pass
# For now, this is a no-op. In a full implementation,
# you'd query mtg_cards and upsert into mtg_cards_mirror.
# This requires cross-database connections which SQLAlchemy
# can handle with proper configuration.
return 0
async def get_card_statistics(db: AsyncSession) -> Dict[str, Any]:
"""
Get statistics about mirrored cards.
Args:
db: Database session
Returns:
Dictionary with statistics
"""
# Total mirrored cards
total_stmt = select(func.count()).select_from(MtgCardMirror)
total = (await db.execute(total_stmt)).scalar()
# Cards by rarity
rarity_stmt = select(MtgCardMirror.rarity, func.count()).group_by(
MtgCardMirror.rarity
)
rarity_result = await db.execute(rarity_stmt)
rarities = {row[0]: row[1] for row in rarity_result if row[0]}
# Cards by type
type_stmt = select(MtgCardMirror.type_line, func.count()).group_by(
MtgCardMirror.type_line
)
type_result = await db.execute(type_stmt)
types = {row[0]: row[1] for row in type_result if row[0]}
return {
"total_mirrored_cards": total,
"cards_by_rarity": rarities,
"cards_by_type": types,
}
+82
View File
@@ -903,6 +903,88 @@ class MTGJSONManager:
'duration': duration,
})
async def sync_mirrors(self):
"""Sync mirrored cards from mtg_cards to mtg_cards_mirror.
This should be called after a successful refresh to ensure
the mirror table is up-to-date with the latest card data.
"""
from sqlalchemy import insert, update
logger.info("Syncing card mirrors...")
async with mtg_async_session() as session:
# Get all cards from mtg_cards
cards_stmt = text("""
SELECT id, name, mana_cost, type_line, oracle_text, power, toughness,
rarity, layout, artist, flavor_text, numbers, identifiers, images,
image, card_parts, keywords, legalities, set_code, set_name
FROM mtg_cards
""")
result = await session.execute(cards_stmt)
cards = result.fetchall()
synced_count = 0
for card in cards:
# Upsert into mtg_cards_mirror
await session.execute(text("""
INSERT INTO mtg_cards_mirror (source_id, name, mana_cost, type_line, oracle_text,
power, toughness, rarity, layout, artist, flavor_text,
numbers, identifiers, images, image, card_parts,
keywords, legalities, set_code, set_name)
VALUES (:source_id, :name, :mana_cost, :type_line, :oracle_text,
:power, :toughness, :rarity, :layout, :artist, :flavor_text,
:numbers, :identifiers, :images, :image, :card_parts,
:keywords, :legalities, :set_code, :set_name)
ON CONFLICT (name) DO UPDATE SET
mana_cost = EXCLUDED.mana_cost,
type_line = EXCLUDED.type_line,
oracle_text = EXCLUDED.oracle_text,
power = EXCLUDED.power,
toughness = EXCLUDED.toughness,
rarity = EXCLUDED.rarity,
layout = EXCLUDED.layout,
artist = EXCLUDED.artist,
flavor_text = EXCLUDED.flavor_text,
numbers = EXCLUDED.numbers,
identifiers = EXCLUDED.identifiers,
images = EXCLUDED.images,
image = EXCLUDED.image,
card_parts = EXCLUDED.card_parts,
keywords = EXCLUDED.keywords,
legalities = EXCLUDED.legalities,
set_code = EXCLUDED.set_code,
set_name = EXCLUDED.set_name,
synced_at = CURRENT_TIMESTAMP
"""), {
'source_id': card[0],
'name': card[1],
'mana_cost': card[2],
'type_line': card[3],
'oracle_text': card[4],
'power': card[5],
'toughness': card[6],
'rarity': card[7],
'layout': card[8],
'artist': card[9],
'flavor_text': card[10],
'numbers': card[11],
'identifiers': card[12],
'images': card[13],
'image': card[14],
'card_parts': card[15],
'keywords': card[16],
'legalities': card[17],
'set_code': card[18],
'set_name': card[19],
})
synced_count += 1
await session.commit()
logger.info(f"Synced {synced_count} card mirrors")
return synced_count
async def run_refresh(self) -> bool:
"""Run complete refresh cycle from local files."""
logger.info("Starting local file refresh")