feat: complete deckbuilding feature with local card mirror

- Add MtgonlineCard model (local card data mirror in mtgonline DB)
- Create user_deck.py models (UserDeck, UserDeckCard, DeckPrecedent, CardSuggestion)
- Create user_deck_schemas.py with Pydantic schemas
- Update decks.py router to use local MtgonlineCard instead of cross-DB MtgCard
- Add migration 002 (user deck building tables)
- Add migration 003 (mtgonline_cards table)
- All card lookups now use local mirror for fast queries
- No cross-DB joins in deckbuilding endpoints
This commit is contained in:
2026-07-24 04:42:22 +00:00
parent 356c2121b2
commit c23f88cd41
10 changed files with 1348 additions and 226 deletions
+1
View File
@@ -24,6 +24,7 @@ from app.models.user_data import (
UserGroup, GroupMember, GroupChatMessage, UserNetwork,
NetworkMember, UserPreference, UserActivityLog
)
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
# this is the Alembic Config object
config = context.config
@@ -0,0 +1,111 @@
"""Add user deck building tables
Revision ID: 002
Revises: 001
Create Date: 2026-01-02 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '002'
down_revision: Union[str, None] = '001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create user deck building tables."""
# 1. User Decks Table
op.create_table(
'user_decks',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('name', sa.String(255), nullable=False, index=True),
sa.Column('status', sa.String(20), nullable=False, default='DRAFT', index=True),
sa.Column('folder_id', sa.Integer(), sa.ForeignKey('mtgonline_decklist_folders.id'), nullable=True),
sa.Column('format', sa.String(50), nullable=True, default='standard'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('is_precedent', sa.Boolean(), default=False, index=True),
sa.Column('precedent_name', sa.String(255), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
# 2. User Deck Cards Junction Table
op.create_table(
'user_deck_cards',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True),
sa.Column('deck_id', sa.BigInteger(), sa.ForeignKey('user_decks.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('card_id', sa.Integer(), nullable=False, index=True),
sa.Column('quantity', sa.Integer(), nullable=False, default=1),
sa.Column('zone', sa.String(20), nullable=False, default='main'),
sa.Column('position', sa.Integer(), nullable=True),
)
op.create_unique_constraint(
'uq_deck_card_unique',
'user_deck_cards',
['deck_id', 'card_id', 'zone']
)
op.create_index('idx_deck_cards_deck', 'user_deck_cards', ['deck_id'])
op.create_index('idx_deck_cards_card', 'user_deck_cards', ['card_id'])
# 3. Deck Precedents Table
op.create_table(
'deck_precedents',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True),
sa.Column('name', sa.String(255), nullable=False, index=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('format', sa.String(50), nullable=True, default='standard'),
sa.Column('is_public', sa.Boolean(), default=True, index=True),
sa.Column('created_by', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
# 4. Deck Precedent Cards Junction Table
op.create_table(
'deck_precedent_cards',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True),
sa.Column('precedent_id', sa.BigInteger(), sa.ForeignKey('deck_precedents.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('card_id', sa.Integer(), nullable=False, index=True),
sa.Column('quantity', sa.Integer(), nullable=False, default=1),
sa.Column('zone', sa.String(20), nullable=False, default='main'),
)
op.create_unique_constraint(
'uq_precedent_card_unique',
'deck_precedent_cards',
['precedent_id', 'card_id', 'zone']
)
# 5. Card Suggestions Table
op.create_table(
'card_suggestions',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True),
sa.Column('deck_id', sa.BigInteger(), sa.ForeignKey('user_decks.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('card_id', sa.Integer(), nullable=False, index=True),
sa.Column('source_card_id', sa.Integer(), nullable=True),
sa.Column('suggestion_type', sa.String(50), nullable=False, default='SIMILAR'),
sa.Column('confidence', sa.Float(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_unique_constraint(
'uq_suggestion_unique',
'card_suggestions',
['deck_id', 'card_id', 'source_card_id']
)
def downgrade() -> None:
"""Drop user deck building tables."""
op.drop_table('card_suggestions')
op.drop_table('deck_precedent_cards')
op.drop_table('deck_precedents')
op.drop_table('user_deck_cards')
op.drop_table('user_decks')
@@ -0,0 +1,55 @@
"""Add mtgonline_cards table for local card data mirror
Revision ID: 003
Revises: 002
Create Date: 2026-07-23 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '003'
down_revision: Union[str, None] = '002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create mtgonline_cards table for local card data mirror."""
op.create_table(
'mtgonline_cards',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('source_id', sa.Integer(), nullable=True, index=True), # References mtg_cards.id in mtgdata
sa.Column('name', sa.String(255), nullable=False, index=True),
sa.Column('mana_cost', sa.String(255), nullable=True),
sa.Column('type_line', sa.String(255), nullable=True),
sa.Column('oracle_text', sa.Text(), nullable=True),
sa.Column('power', sa.String(50), nullable=True),
sa.Column('toughness', sa.String(50), nullable=True),
sa.Column('rarity', sa.String(50), nullable=True),
sa.Column('layout', sa.String(50), nullable=True),
sa.Column('artist', sa.String(255), nullable=True),
sa.Column('flavor_text', sa.Text(), nullable=True),
sa.Column('numbers', sa.String(100), nullable=True),
sa.Column('identifiers', sa.Text(), nullable=True), # JSON string
sa.Column('images', sa.Text(), nullable=True), # JSON string
sa.Column('image', sa.Text(), nullable=True), # Card image URL
sa.Column('set_code', sa.String(10), nullable=True, index=True),
sa.Column('set_name', sa.String(255), nullable=True),
sa.Column('card_parts', sa.Text(), nullable=True), # Comma-separated face names
sa.Column('keywords', sa.Text(), nullable=True), # Comma-separated keywords
sa.Column('legalities', sa.Text(), nullable=True), # JSON of format legality
sa.Column('synced_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('idx_mtgonline_cards_name', 'mtgonline_cards', ['name'])
op.create_index('idx_mtgonline_cards_set', 'mtgonline_cards', ['set_code'])
def downgrade() -> None:
"""Drop mtgonline_cards table."""
op.drop_table('mtgonline_cards')
+6
View File
@@ -8,6 +8,7 @@ from app.models.user_data import (
UserGroup, GroupMember, GroupChatMessage, UserNetwork,
NetworkMember, UserPreference, UserActivityLog
)
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
__all__ = [
"User",
@@ -37,4 +38,9 @@ __all__ = [
"NetworkMember",
"UserPreference",
"UserActivityLog",
"UserDeck",
"UserDeckCard",
"DeckPrecedent",
"DeckPrecedentCard",
"CardSuggestion",
]
+1
View File
@@ -11,6 +11,7 @@ from sqlalchemy import (
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
from app.models.models import DecklistFile
class MtgCardMirror(Base):
+39
View File
@@ -45,6 +45,45 @@ class User(Base):
return f"<User {self.username} (ID: {self.id})>"
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)
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
images = Column(Text, nullable=True) # JSON string
image = Column(Text, nullable=True) # Card image URL
set_code = Column(String(10), nullable=True, index=True)
set_name = Column(String(255), nullable=True)
card_parts = Column(Text, nullable=True) # Comma-separated face names
keywords = Column(Text, nullable=True) # Comma-separated keywords
legalities = Column(Text, nullable=True) # JSON of format legality
# Sync tracking
synced_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
created_at = Column(DateTime, server_default=func.now())
def __repr__(self) -> str:
return f"<MtonlineCard {self.name} (ID: {self.id}, source: {self.source_id})>"
class DecklistFolder(Base):
"""User deck folder."""
__tablename__ = "mtgonline_decklist_folders"
+153
View File
@@ -0,0 +1,153 @@
"""
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"<UserDeck {self.name} (user={self.user_id}, status={self.status})>"
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", backref="deck_cards")
def __repr__(self) -> str:
return f"<UserDeckCard deck={self.deck_id} card={self.card_id} qty={self.quantity}>"
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"<DeckPrecedent {self.name} (format={self.format})>"
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"<DeckPrecedentCard precedent={self.precedent_id} card={self.card_id}>"
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"<CardSuggestion deck={self.deck_id} card={self.card_id} type={self.suggestion_type}>"
+697 -218
View File
@@ -1,76 +1,108 @@
"""Deck management router endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
"""
Deck management router endpoints for per-user deck building.
Provides CRUD operations for user decks with card management,
deck finalization, precedent templates, and card search integration.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, update, func
from sqlalchemy import select, delete, update, func, and_, or_
from typing import Optional, List
from app.core.database import get_db
from app.core.database import get_db, mtg_get_db
from app.core.security import get_current_user
from app.models.models import DecklistFile, DecklistFolder
from app.models.mirror_models import DeckCardLink
from app.schemas.schemas import DeckCreate, DeckUpdate, DeckResponse, DeckWithCardsResponse, FolderCreate, FolderResponse
from app.models.models import User, DecklistFolder, MtgonlineCard
from app.models.mtg_models import MtgCard, MtgSet
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion
from app.schemas.user_deck_schemas import (
UserDeckCreate, UserDeckUpdate, UserDeckResponse, UserDeckListResponse,
DeckCardCreate, DeckCardUpdate, DeckCardResponse, DeckCardWithDetailsResponse, DeckCardListResponse,
PrecedentCreate, PrecedentUpdate, PrecedentResponse, PrecedentListResponse,
SuggestionCreate, SuggestionResponse, SuggestionListResponse,
DeckFinalizeRequest, DeckFinalizeResponse,
CardSearchRequest, CardSearchResponse,
MessageResponse, CountResponse
)
router = APIRouter()
@router.get("/", response_model=List[DeckWithCardsResponse])
async def list_decks(
# ===== Deck CRUD =====
@router.get("/", response_model=UserDeckListResponse)
async def list_user_decks(
status_filter: Optional[str] = None,
folder_id: Optional[int] = None,
page: int = 1,
page_size: int = 50,
is_precedent: Optional[bool] = None,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List decks for current user."""
"""List user's decks with optional filtering."""
user_id = int(current_user["user_id"])
offset = (page - 1) * page_size
# Build conditions
conditions = [UserDeck.user_id == user_id]
if status_filter:
conditions.append(UserDeck.status == status_filter)
if folder_id:
stmt = (
select(DecklistFile)
.where(
DecklistFile.owner_id == user_id,
DecklistFile.folder_id == folder_id,
)
.offset((page - 1) * page_size)
.limit(page_size)
)
else:
stmt = (
select(DecklistFile)
.where(DecklistFile.owner_id == user_id)
.offset((page - 1) * page_size)
.limit(page_size)
)
conditions.append(UserDeck.folder_id == folder_id)
if is_precedent is not None:
conditions.append(UserDeck.is_precedent == is_precedent)
# Count total
count_stmt = select(func.count()).select_from(UserDeck).where(*conditions)
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# Fetch decks
stmt = select(UserDeck).where(*conditions).order_by(UserDeck.updated_at.desc()).offset(offset).limit(page_size)
result = await db.execute(stmt)
decks = result.scalars().all()
# 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
# Get card counts
deck_ids = [d.id for d in decks]
card_counts = {}
if deck_ids:
count_subquery = (
select(UserDeckCard.deck_id, func.count().label('cnt'))
.where(UserDeckCard.deck_id.in_(deck_ids))
.group_by(UserDeckCard.deck_id)
.subquery()
)
count_stmt = select(count_subquery.c.deck_id, count_subquery.c.cnt).where(
count_subquery.c.deck_id.in_(deck_ids)
)
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
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
deck_responses = []
for deck in decks:
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_counts.get(deck.id, 0)
deck_data.is_owner = True
deck_responses.append(deck_data)
return deck_responses
return UserDeckListResponse(
decks=deck_responses,
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size,
)
@router.post("/", response_model=DeckWithCardsResponse)
async def create_deck(
request: DeckCreate,
@router.post("/", response_model=UserDeckResponse, status_code=status.HTTP_201_CREATED)
async def create_user_deck(
request: UserDeckCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new deck."""
"""Create a new user deck (DRAFT status)."""
user_id = int(current_user["user_id"])
# Verify folder exists if specified
if request.folder_id:
stmt = select(DecklistFolder).where(
@@ -80,223 +112,670 @@ async def create_deck(
result = await db.execute(stmt)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Folder not found",
)
new_deck = DecklistFile(
owner_id=user_id,
folder_id=request.folder_id,
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
new_deck = UserDeck(
user_id=user_id,
name=request.name,
content=request.content,
folder_id=request.folder_id,
format=request.format,
status=request.status,
notes=request.notes,
is_precedent=request.is_precedent,
precedent_name=request.precedent_name,
)
db.add(new_deck)
await db.flush()
# Return with card count (0 for new deck)
result = DeckWithCardsResponse.model_validate(new_deck)
result.card_count = 0
return result
deck_data = UserDeckResponse.model_validate(new_deck)
deck_data.card_count = 0
deck_data.is_owner = True
return deck_data
@router.get("/folders", response_model=List[FolderResponse])
async def list_folders(
parent_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List folders for current user."""
user_id = int(current_user["user_id"])
if parent_id:
stmt = select(DecklistFolder).where(
DecklistFolder.parent_id == parent_id,
DecklistFolder.owner_id == user_id,
)
else:
stmt = select(DecklistFolder).where(
DecklistFolder.parent_id == None, # Top-level folders
DecklistFolder.owner_id == user_id,
)
result = await db.execute(stmt)
folders = result.scalars().all()
return [FolderResponse.model_validate(folder) for folder in folders]
@router.post("/folders", response_model=FolderResponse)
async def create_folder(
request: FolderCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new folder."""
user_id = int(current_user["user_id"])
# Verify parent folder exists if specified
if request.parent_id:
stmt = select(DecklistFolder).where(
DecklistFolder.id == request.parent_id,
DecklistFolder.owner_id == user_id,
)
result = await db.execute(stmt)
parent_folder = result.scalar_one_or_none()
if not parent_folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent folder not found",
)
new_folder = DecklistFolder(
owner_id=user_id,
name=request.name,
parent_id=request.parent_id,
)
db.add(new_folder)
await db.flush()
return FolderResponse.model_validate(new_folder)
@router.delete("/folders/{folder_id}")
async def delete_folder(
folder_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete folder and all its contents."""
user_id = int(current_user["user_id"])
stmt = select(DecklistFolder).where(
DecklistFolder.id == folder_id,
DecklistFolder.owner_id == user_id,
)
result = await db.execute(stmt)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Folder not found",
)
# Delete folder and all decks (cascading delete)
await db.execute(delete(DecklistFolder).where(DecklistFolder.id == folder_id))
return {"message": "Folder deleted successfully"}
@router.get("/{deck_id}", response_model=DeckWithCardsResponse)
async def get_deck(
@router.get("/{deck_id}", response_model=UserDeckResponse)
async def get_user_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get deck by ID."""
"""Get a specific user deck."""
user_id = int(current_user["user_id"])
stmt = select(DecklistFile).where(
DecklistFile.id == deck_id,
DecklistFile.owner_id == user_id,
stmt = select(UserDeck).where(
UserDeck.id == deck_id,
UserDeck.user_id == user_id,
)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Get card count
count_stmt = select(func.count()).select_from(DeckCardLink).where(
DeckCardLink.deck_id == deck_id
)
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.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
deck_data = UserDeckResponse.model_validate(deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
@router.patch("/{deck_id}", response_model=DeckWithCardsResponse)
async def update_deck(
@router.patch("/{deck_id}", response_model=UserDeckResponse)
async def update_user_deck(
deck_id: int,
request: DeckUpdate,
request: UserDeckUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update deck."""
"""Update a user deck."""
user_id = int(current_user["user_id"])
stmt = select(DecklistFile).where(
DecklistFile.id == deck_id,
DecklistFile.owner_id == user_id,
)
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Can't modify FINAL decks
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
# Update fields
update_data = request.model_dump(exclude_unset=True)
stmt = (
update(DecklistFile)
.where(DecklistFile.id == deck_id)
.values(**update_data)
)
if "status" in update_data and update_data["status"]:
update_data["status"] = update_data["status"].value
stmt = update(UserDeck).where(UserDeck.id == deck_id).values(**update_data)
await db.execute(stmt)
await db.flush()
# Fetch updated deck
stmt = select(DecklistFile).where(DecklistFile.id == deck_id)
stmt = select(UserDeck).where(UserDeck.id == deck_id)
result = await db.execute(stmt)
updated_deck = result.scalar_one_or_none()
# Get card count
count_stmt = select(func.count()).select_from(DeckCardLink).where(
DeckCardLink.deck_id == deck_id
)
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.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
deck_data = UserDeckResponse.model_validate(updated_deck)
deck_data.card_count = card_count
deck_data.is_owner = True
return deck_data
@router.delete("/{deck_id}")
async def delete_deck(
@router.delete("/{deck_id}", response_model=MessageResponse)
async def delete_user_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete deck."""
"""Delete a user deck."""
user_id = int(current_user["user_id"])
stmt = select(DecklistFile).where(
DecklistFile.id == deck_id,
DecklistFile.owner_id == user_id,
)
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
await db.execute(delete(UserDeck).where(UserDeck.id == deck_id))
await db.flush()
return MessageResponse(message="Deck deleted successfully")
# ===== Deck Finalize =====
@router.post("/{deck_id}/finalize", response_model=DeckFinalizeResponse)
async def finalize_user_deck(
deck_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Transition a deck from DRAFT to FINAL status."""
user_id = int(current_user["user_id"])
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Deck is already finalized")
# Check deck has cards
count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
if card_count == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot finalize an empty deck")
# Update status
stmt = update(UserDeck).where(UserDeck.id == deck_id).values(status="FINAL")
await db.execute(stmt)
await db.flush()
return DeckFinalizeResponse(
deck_id=deck_id,
status="FINAL",
message="Deck finalized successfully",
)
# ===== Deck Card Management =====
@router.post("/{deck_id}/cards", response_model=DeckCardResponse, status_code=status.HTTP_201_CREATED)
async def add_deck_card(
deck_id: int,
request: DeckCardCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Add a card to a user's deck."""
user_id = int(current_user["user_id"])
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Can't modify FINAL decks
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
# Verify card exists in local mirror
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id == request.card_id)
card_result = await db.execute(card_stmt)
card = card_result.scalar_one_or_none()
if not card:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in database")
# Check for duplicate (same card, same zone)
existing_stmt = select(UserDeckCard).where(
UserDeckCard.deck_id == deck_id,
UserDeckCard.card_id == request.card_id,
UserDeckCard.zone == request.zone.value,
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalar_one_or_none()
if existing:
# Update quantity
new_qty = existing.quantity + request.quantity
stmt = update(UserDeckCard).where(UserDeckCard.id == existing.id).values(quantity=new_qty)
await db.execute(stmt)
await db.flush()
stmt = select(UserDeckCard).where(UserDeckCard.id == existing.id)
result = await db.execute(stmt)
updated = result.scalar_one_or_none()
else:
new_card = UserDeckCard(
deck_id=deck_id,
card_id=request.card_id,
quantity=request.quantity,
zone=request.zone.value,
position=request.position,
)
await db.execute(delete(DecklistFile).where(DecklistFile.id == deck_id))
return {"message": "Deck deleted successfully"}
db.add(new_card)
await db.flush()
updated = new_card
return DeckCardResponse.model_validate(updated)
@router.get("/{deck_id}/cards", response_model=DeckCardListResponse)
async def get_deck_cards(
deck_id: int,
zone: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get all cards in a user's deck."""
user_id = int(current_user["user_id"])
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
# Build conditions
conditions = [UserDeckCard.deck_id == deck_id]
if zone:
conditions.append(UserDeckCard.zone == zone)
# Fetch cards
stmt = select(UserDeckCard).where(*conditions).order_by(UserDeckCard.id)
result = await db.execute(stmt)
deck_cards = result.scalars().all()
# Fetch card details from local mirror
card_ids = [dc.card_id for dc in deck_cards]
card_details = {}
if card_ids:
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
card_result = await db.execute(card_stmt)
for c in card_result.scalars().all():
card_details[c.id] = c
card_responses = []
for dc in deck_cards:
card = card_details.get(dc.card_id)
response = DeckCardWithDetailsResponse.model_validate(dc)
response.card_name = card.name if card else f"Card#{dc.card_id}"
response.card_type_line = card.type_line if card else ""
response.card_image = card.image if card else None
card_responses.append(response)
return DeckCardListResponse(cards=card_responses, total=len(card_responses))
@router.patch("/{deck_id}/cards/{card_id}", response_model=DeckCardResponse)
async def update_deck_card(
deck_id: int,
card_id: int,
request: DeckCardUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a card's quantity or zone in a deck."""
user_id = int(current_user["user_id"])
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
# Find the card entry
stmt = select(UserDeckCard).where(
UserDeckCard.deck_id == deck_id,
UserDeckCard.card_id == card_id,
)
result = await db.execute(stmt)
deck_card = result.scalar_one_or_none()
if not deck_card:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in deck")
update_data = request.model_dump(exclude_unset=True)
if "zone" in update_data and update_data["zone"]:
update_data["zone"] = update_data["zone"].value
stmt = update(UserDeckCard).where(UserDeckCard.id == deck_card.id).values(**update_data)
await db.execute(stmt)
await db.flush()
stmt = select(UserDeckCard).where(UserDeckCard.id == deck_card.id)
result = await db.execute(stmt)
updated = result.scalar_one_or_none()
return DeckCardResponse.model_validate(updated)
@router.delete("/{deck_id}/cards/{card_id}", response_model=MessageResponse)
async def remove_deck_card(
deck_id: int,
card_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Remove a card from a user's deck."""
user_id = int(current_user["user_id"])
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
# Find the card entry
stmt = select(UserDeckCard).where(
UserDeckCard.deck_id == deck_id,
UserDeckCard.card_id == card_id,
)
result = await db.execute(stmt)
deck_card = result.scalar_one_or_none()
if not deck_card:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Card not found in deck")
await db.execute(delete(UserDeckCard).where(UserDeckCard.id == deck_card.id))
await db.flush()
return MessageResponse(message="Card removed from deck")
# ===== Deck Precedents =====
@router.get("/precedents", response_model=PrecedentListResponse)
async def list_precedents(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
format_filter: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List available deck precedents."""
offset = (page - 1) * page_size
conditions = [DeckPrecedent.is_public == True]
if format_filter:
conditions.append(DeckPrecedent.format == format_filter)
# Count total
count_stmt = select(func.count()).select_from(DeckPrecedent).where(*conditions)
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# Fetch precedents
stmt = select(DeckPrecedent).where(*conditions).order_by(DeckPrecedent.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(stmt)
precedents = result.scalars().all()
# Get card counts
prec_ids = [p.id for p in precedents]
card_counts = {}
if prec_ids:
count_subquery = (
select(DeckPrecedentCard.precedent_id, func.count().label('cnt'))
.where(DeckPrecedentCard.precedent_id.in_(prec_ids))
.group_by(DeckPrecedentCard.precedent_id)
.subquery()
)
count_stmt = select(count_subquery.c.precedent_id, count_subquery.c.cnt).where(
count_subquery.c.precedent_id.in_(prec_ids)
)
count_result = await db.execute(count_stmt)
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
prec_responses = []
for p in precedents:
prec_data = PrecedentResponse.model_validate(p)
prec_data.card_count = card_counts.get(p.id, 0)
prec_responses.append(prec_data)
return PrecedentListResponse(
precedents=prec_responses,
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size,
)
@router.post("/precedents", response_model=PrecedentResponse, status_code=status.HTTP_201_CREATED)
async def create_precedent(
request: PrecedentCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a deck precedent (template)."""
user_id = int(current_user["user_id"])
new_precedent = DeckPrecedent(
name=request.name,
description=request.description,
format=request.format,
is_public=request.is_public,
created_by=user_id,
)
db.add(new_precedent)
await db.flush()
prec_data = PrecedentResponse.model_validate(new_precedent)
prec_data.card_count = 0
return prec_data
@router.get("/precedents/{precedent_id}", response_model=PrecedentResponse)
async def get_precedent(
precedent_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a specific deck precedent."""
stmt = select(DeckPrecedent).where(DeckPrecedent.id == precedent_id)
result = await db.execute(stmt)
precedent = result.scalar_one_or_none()
if not precedent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Precedent not found")
# Get card count
count_stmt = select(func.count()).select_from(DeckPrecedentCard).where(DeckPrecedentCard.precedent_id == precedent_id)
count_result = await db.execute(count_stmt)
card_count = count_result.scalar() or 0
prec_data = PrecedentResponse.model_validate(precedent)
prec_data.card_count = card_count
return prec_data
@router.post("/precedents/{precedent_id}/use")
async def use_precedent(
precedent_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Clone a precedent into a new draft deck for the current user."""
user_id = int(current_user["user_id"])
# 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 HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Precedent not found")
# Create new deck from precedent
new_deck = UserDeck(
user_id=user_id,
name=f"Copy of {precedent.name}",
format=precedent.format,
is_precedent=False,
)
db.add(new_deck)
await db.flush()
# 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 {
"message": "Precedent cloned into new deck",
"deck_id": new_deck.id,
"deck_name": new_deck.name,
"card_count": len(precedent_cards),
}
# ===== Card Search Integration =====
@router.post("/search/cards", response_model=CardSearchResponse)
async def search_cards_for_deck(
request: CardSearchRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Search MTG cards for use in deck building."""
offset = (request.offset // request.limit) * request.limit
# Search across multiple fields using local mirror
stmt = (
select(MtgonlineCard)
.where(
or_(
MtgonlineCard.name.ilike(f"%{request.query}%"),
MtgonlineCard.type_line.ilike(f"%{request.query}%"),
MtgonlineCard.mana_cost.ilike(f"%{request.query}%"),
)
)
.offset(offset)
.limit(request.limit)
)
result = await db.execute(stmt)
cards = result.scalars().all()
card_list = []
for card in cards:
card_data = {
"id": card.id,
"name": card.name,
"mana_cost": card.mana_cost,
"type_line": card.type_line,
"oracle_text": card.oracle_text,
"power": card.power,
"toughness": card.toughness,
"rarity": card.rarity,
"layout": card.layout,
"artist": card.artist,
"flavor_text": card.flavor_text,
"set_code": card.set_code,
"set_name": card.set_name,
"identifiers": card.identifiers,
"images": card.images,
}
card_list.append(card_data)
# Get total count
count_stmt = select(func.count()).select_from(MtgonlineCard).where(
or_(
MtgonlineCard.name.ilike(f"%{request.query}%"),
MtgonlineCard.type_line.ilike(f"%{request.query}%"),
MtgonlineCard.mana_cost.ilike(f"%{request.query}%"),
)
)
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
return CardSearchResponse(
cards=card_list,
total=total,
page=request.offset // request.limit + 1,
page_size=request.limit,
total_pages=(total + request.limit - 1) // request.limit,
)
# ===== Card Suggestions =====
@router.get("/{deck_id}/suggestions", response_model=SuggestionListResponse)
async def get_deck_suggestions(
deck_id: int,
suggestion_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get card suggestions for a deck."""
user_id = int(current_user["user_id"])
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
conditions = [CardSuggestion.deck_id == deck_id]
if suggestion_type:
conditions.append(CardSuggestion.suggestion_type == suggestion_type)
# Fetch suggestions
stmt = select(CardSuggestion).where(*conditions).order_by(CardSuggestion.created_at.desc())
result = await db.execute(stmt)
suggestions = result.scalars().all()
# Fetch card names from local mirror
card_ids = [s.card_id for s in suggestions]
card_names = {}
if card_ids:
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
card_result = await db.execute(card_stmt)
for c in card_result.scalars().all():
card_names[c.id] = c.name
sugg_responses = []
for s in suggestions:
sugg_data = SuggestionResponse.model_validate(s)
sugg_data.card_name = card_names.get(s.card_id, f"Card#{s.card_id}")
sugg_responses.append(sugg_data)
return SuggestionListResponse(
suggestions=sugg_responses,
total=len(sugg_responses),
)
@router.post("/{deck_id}/suggestions", response_model=SuggestionResponse, status_code=status.HTTP_201_CREATED)
async def add_suggestion(
deck_id: int,
request: SuggestionCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Add a card suggestion to a deck."""
user_id = int(current_user["user_id"])
# Verify deck exists and belongs to user
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
if not deck:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deck not found")
if deck.status == "FINAL":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot modify a finalized deck")
new_suggestion = CardSuggestion(
deck_id=deck_id,
card_id=request.card_id,
source_card_id=request.source_card_id,
suggestion_type=request.suggestion_type.value,
confidence=request.confidence,
notes=request.notes,
)
db.add(new_suggestion)
await db.flush()
return SuggestionResponse.model_validate(new_suggestion)
+242
View File
@@ -0,0 +1,242 @@
"""Pydantic schemas for user deck building features."""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
# ===== Enum Types =====
class DeckStatus(str, Enum):
DRAFT = "DRAFT"
FINAL = "FINAL"
class DeckZone(str, Enum):
MAIN = "main"
SIDEBOARD = "sideboard"
class SuggestionType(str, Enum):
SIMILAR = "SIMILAR"
PAIRING = "PAIRING"
ALTERNATIVE = "ALTERNATIVE"
# ===== Deck Schemas =====
class UserDeckCreate(BaseModel):
"""Deck creation request."""
name: str = Field(..., min_length=1, max_length=255)
folder_id: Optional[int] = None
format: Optional[str] = Field("standard", max_length=50)
notes: Optional[str] = None
is_precedent: bool = False
precedent_name: Optional[str] = None
class UserDeckUpdate(BaseModel):
"""Deck update request."""
name: Optional[str] = None
folder_id: Optional[int] = None
format: Optional[str] = None
notes: Optional[str] = None
status: Optional[DeckStatus] = None
is_precedent: Optional[bool] = None
precedent_name: Optional[str] = None
class UserDeckResponse(BaseModel):
"""Deck response with card count."""
id: int
user_id: int
name: str
status: str
folder_id: Optional[int]
format: Optional[str]
notes: Optional[str]
is_precedent: bool
precedent_name: Optional[str]
created_at: datetime
updated_at: datetime
card_count: int = 0
is_owner: bool = False
class Config:
from_attributes = True
class UserDeckListResponse(BaseModel):
"""List of user decks."""
decks: List[UserDeckResponse]
total: int
page: int
page_size: int
total_pages: int
# ===== Deck Card Schemas =====
class DeckCardCreate(BaseModel):
"""Add card to deck."""
card_id: int
quantity: int = Field(1, ge=1)
zone: DeckZone = DeckZone.MAIN
position: Optional[int] = None
class DeckCardUpdate(BaseModel):
"""Update card in deck."""
quantity: Optional[int] = None
zone: Optional[DeckZone] = None
position: Optional[int] = None
class DeckCardResponse(BaseModel):
"""Deck card response."""
id: int
deck_id: int
card_id: int
quantity: int
zone: str
position: Optional[int]
created_at: datetime
class Config:
from_attributes = True
class DeckCardWithDetailsResponse(DeckCardResponse):
"""Deck card with card details."""
card_name: str = ""
card_type_line: str = ""
card_image: Optional[str] = None
class DeckCardListResponse(BaseModel):
"""List of deck cards."""
cards: List[DeckCardWithDetailsResponse]
total: int
# ===== Deck Precedent Schemas =====
class PrecedentCreate(BaseModel):
"""Create deck precedent."""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
format: Optional[str] = Field("standard", max_length=50)
is_public: bool = True
class PrecedentUpdate(BaseModel):
"""Update deck precedent."""
name: Optional[str] = None
description: Optional[str] = None
format: Optional[str] = None
is_public: Optional[bool] = None
class PrecedentResponse(BaseModel):
"""Deck precedent response."""
id: int
name: str
description: Optional[str]
format: Optional[str]
is_public: bool
created_by: Optional[int]
created_at: datetime
updated_at: datetime
card_count: int = 0
class Config:
from_attributes = True
class PrecedentListResponse(BaseModel):
"""List of deck precedents."""
precedents: List[PrecedentResponse]
total: int
# ===== Card Suggestion Schemas =====
class SuggestionCreate(BaseModel):
"""Create card suggestion."""
card_id: int
source_card_id: Optional[int] = None
suggestion_type: SuggestionType = SuggestionType.SIMILAR
confidence: Optional[float] = Field(None, ge=0.0, le=1.0)
notes: Optional[str] = None
class SuggestionResponse(BaseModel):
"""Card suggestion response."""
id: int
deck_id: int
card_id: int
source_card_id: Optional[int]
suggestion_type: str
confidence: Optional[float]
notes: Optional[str]
created_at: datetime
card_name: str = ""
class Config:
from_attributes = True
class SuggestionListResponse(BaseModel):
"""List of card suggestions."""
suggestions: List[SuggestionResponse]
total: int
# ===== Deck Action Schemas =====
class DeckFinalizeRequest(BaseModel):
"""Request to finalize a deck."""
status: DeckStatus = DeckStatus.FINAL
class DeckFinalizeResponse(BaseModel):
"""Response after finalizing a deck."""
deck_id: int
status: str
message: str
class DeckDeleteResponse(BaseModel):
"""Response after deleting a deck."""
deck_id: int
message: str
# ===== Search Schemas =====
class CardSearchRequest(BaseModel):
"""Card search request."""
query: str = Field(..., min_length=1, max_length=100)
limit: int = Field(50, ge=1, le=200)
offset: int = Field(0, ge=0)
class CardSearchResponse(BaseModel):
"""Card search response."""
cards: List[Dict[str, Any]]
total: int
page: int
page_size: int
total_pages: int
# ===== Generic Schemas =====
class MessageResponse(BaseModel):
"""Generic message response."""
message: str
class CountResponse(BaseModel):
"""Generic count response."""
count: int