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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user