Files
mtgonline/backend/app/services/deck_manager.py
T
akadmin b7056fe0d9 Fix SQLite autoincrement and test configuration
- Removed autoincrement=True from BigInteger primary keys (UserDeck, UserDeckCard, DeckPrecedent, etc.)
- SQLite doesn't support autoincrement on BigInteger columns
- Updated conftest.py test engine configuration
- All admin tests passing (5/5)
- All auth tests passing (7/7)
- Game model tests passing (13/13)
- Deck tests still failing due to SQLite RETURNING clause issue
2026-08-28 03:43:14 +00:00

273 lines
8.9 KiB
Python

"""Deck manager service."""
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete, func, text
from sqlalchemy.orm import selectinload
from app.models.models import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, MtgonlineCard
class DeckManager:
"""Deck manager service."""
@staticmethod
async def create_deck(
db: AsyncSession,
user_id: int,
name: str,
folder_id: Optional[int] = None,
format: str = "standard",
notes: Optional[str] = None,
is_precedent: bool = False,
precedent_name: Optional[str] = None,
) -> UserDeck:
"""Create a new deck using ORM approach.
Creates a UserDeck instance, adds it to the session, flushes to
obtain the auto-generated id, then queries to return the full object.
"""
new_deck = UserDeck(
user_id=user_id,
name=name,
status="DRAFT",
folder_id=folder_id,
format=format,
notes=notes,
is_precedent=is_precedent,
precedent_name=precedent_name,
)
db.add(new_deck)
await db.flush()
# Query to get the fully loaded object with its auto-generated id
# (SQLite doesn't support RETURNING, so new_deck.id may be None after flush)
stmt = select(UserDeck).where(
UserDeck.user_id == user_id,
UserDeck.name == name,
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def get_deck(db: AsyncSession, deck_id: int, user_id: int) -> Optional[UserDeck]:
"""Get a deck by ID."""
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def list_decks(
db: AsyncSession,
user_id: int,
status_filter: Optional[str] = None,
folder_id: Optional[int] = None,
is_precedent: Optional[bool] = None,
page: int = 1,
page_size: int = 50,
) -> List[UserDeck]:
"""List user's decks with filtering."""
conditions = [UserDeck.user_id == user_id]
if status_filter:
conditions.append(UserDeck.status == status_filter)
if folder_id:
conditions.append(UserDeck.folder_id == folder_id)
if is_precedent is not None:
conditions.append(UserDeck.is_precedent == is_precedent)
offset = (page - 1) * page_size
stmt = select(UserDeck).where(*conditions).order_by(UserDeck.updated_at.desc()).offset(offset).limit(page_size)
result = await db.execute(stmt)
return result.scalars().all()
@staticmethod
async def update_deck(
db: AsyncSession,
deck_id: int,
user_id: int,
name: Optional[str] = None,
folder_id: Optional[int] = None,
format: Optional[str] = None,
notes: Optional[str] = None,
) -> Optional[UserDeck]:
"""Update a deck."""
deck = await DeckManager.get_deck(db, deck_id, user_id)
if not deck:
return None
if deck.status == "FINAL":
raise ValueError("Cannot modify a finalized deck")
if name:
deck.name = name
if folder_id is not None:
deck.folder_id = folder_id
if format:
deck.format = format
if notes is not None:
deck.notes = notes
await db.flush()
return deck
@staticmethod
async def delete_deck(db: AsyncSession, deck_id: int, user_id: int) -> bool:
"""Delete a deck."""
deck = await DeckManager.get_deck(db, deck_id, user_id)
if not deck:
return False
await db.execute(delete(UserDeck).where(UserDeck.id == deck_id))
await db.flush()
return True
@staticmethod
async def finalize_deck(db: AsyncSession, deck_id: int, user_id: int) -> Optional[UserDeck]:
"""Transition a deck from DRAFT to FINAL status."""
deck = await DeckManager.get_deck(db, deck_id, user_id)
if not deck:
return None
if deck.status == "FINAL":
raise ValueError("Deck is already finalized")
# Check deck has cards
card_count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
card_count_result = await db.execute(card_count_stmt)
card_count = card_count_result.scalar() or 0
if card_count == 0:
raise ValueError("Cannot finalize an empty deck")
deck.status = "FINAL"
await db.flush()
return deck
@staticmethod
async def add_card_to_deck(
db: AsyncSession,
deck_id: int,
card_id: int,
quantity: int = 1,
zone: str = "main",
position: Optional[int] = None,
) -> UserDeckCard:
"""Add a card to a deck."""
deck_card = UserDeckCard(
deck_id=deck_id,
card_id=card_id,
quantity=quantity,
zone=zone,
position=position,
)
db.add(deck_card)
await db.flush()
return deck_card
@staticmethod
async def get_deck_cards(db: AsyncSession, deck_id: int, zone: Optional[str] = None) -> List[UserDeckCard]:
"""Get cards in a deck."""
conditions = [UserDeckCard.deck_id == deck_id]
if zone:
conditions.append(UserDeckCard.zone == zone)
stmt = select(UserDeckCard).where(*conditions).order_by(UserDeckCard.id)
result = await db.execute(stmt)
return result.scalars().all()
@staticmethod
async def update_deck_card(
db: AsyncSession,
deck_card_id: int,
quantity: Optional[int] = None,
zone: Optional[str] = None,
position: Optional[int] = None,
) -> Optional[UserDeckCard]:
"""Update a card in a deck."""
stmt = select(UserDeckCard).where(UserDeckCard.id == deck_card_id)
result = await db.execute(stmt)
deck_card = result.scalar_one_or_none()
if not deck_card:
return None
if quantity is not None:
deck_card.quantity = quantity
if zone:
deck_card.zone = zone
if position is not None:
deck_card.position = position
await db.flush()
return deck_card
@staticmethod
async def remove_card_from_deck(db: AsyncSession, deck_card_id: int) -> bool:
"""Remove a card from a deck."""
stmt = select(UserDeckCard).where(UserDeckCard.id == deck_card_id)
result = await db.execute(stmt)
deck_card = result.scalar_one_or_none()
if not deck_card:
return False
await db.execute(delete(UserDeckCard).where(UserDeckCard.id == deck_card_id))
await db.flush()
return True
@staticmethod
async def clone_precedent(
db: AsyncSession,
precedent_id: int,
user_id: int,
name: Optional[str] = None,
) -> UserDeck:
"""Clone a precedent into a new deck.
Uses raw SQL INSERT with positional parameters to avoid RETURNING clause
incompatibility with SQLite.
"""
# Get precedent
stmt = select(DeckPrecedent).where(DeckPrecedent.id == precedent_id)
result = await db.execute(stmt)
precedent = result.scalar_one_or_none()
if not precedent:
raise ValueError(f"Precedent {precedent_id} not found")
# Create new deck using raw SQL INSERT with positional parameters
# Note: We don't call db.flush() after raw SQL insert to avoid RETURNING clause
new_name = name or f"Copy of {precedent.name}"
insert_sql = text(
"INSERT INTO user_decks (user_id, name, format, is_precedent) "
"VALUES (?, ?, ?, ?)"
)
await db.execute(insert_sql, (user_id, new_name, precedent.format, False))
await db.flush()
# Query for the newly created deck to get the auto-generated id
deck_stmt = select(UserDeck).where(
UserDeck.user_id == user_id,
UserDeck.name == new_name,
)
result = await db.execute(deck_stmt)
new_deck = result.scalar_one_or_none()
if not new_deck:
raise RuntimeError(f"Failed to create deck: {new_name}")
# Copy cards from precedent
card_stmt = select(DeckPrecedentCard).where(DeckPrecedentCard.precedent_id == precedent_id)
card_result = await db.execute(card_stmt)
precedent_cards = card_result.scalars().all()
for pc in precedent_cards:
new_dc = UserDeckCard(
deck_id=new_deck.id,
card_id=pc.card_id,
quantity=pc.quantity,
zone=pc.zone,
)
db.add(new_dc)
await db.flush()
return new_deck