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:
@@ -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')
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>"
|
||||
+674
-195
@@ -1,74 +1,106 @@
|
||||
"""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
|
||||
card_counts = {row[0]: row[1] for row in count_result.fetchall()}
|
||||
|
||||
deck_data = DeckWithCardsResponse.model_validate(deck)
|
||||
deck_data.card_count = card_count
|
||||
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
|
||||
@@ -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",
|
||||
)
|
||||
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,
|
||||
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)
|
||||
if "status" in update_data and update_data["status"]:
|
||||
update_data["status"] = update_data["status"].value
|
||||
|
||||
stmt = (
|
||||
update(DecklistFile)
|
||||
.where(DecklistFile.id == deck_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
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,
|
||||
)
|
||||
db.add(new_card)
|
||||
await db.flush()
|
||||
updated = new_card
|
||||
|
||||
await db.execute(delete(DecklistFile).where(DecklistFile.id == deck_id))
|
||||
return DeckCardResponse.model_validate(updated)
|
||||
|
||||
return {"message": "Deck deleted successfully"}
|
||||
|
||||
@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)
|
||||
|
||||
@@ -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
|
||||
+43
-8
@@ -21,9 +21,33 @@
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"status": "completed",
|
||||
"description": "Per-user deck building with card search, precedents, and suggestions",
|
||||
"key_deliverables": [
|
||||
"UserDeck model (DRAFT/FINAL status)",
|
||||
"UserDeckCard junction table (card_id references mtgonline_cards.id)",
|
||||
"DeckPrecedent and DeckPrecedentCard tables (template support)",
|
||||
"CardSuggestion table (suggestion storage)",
|
||||
"Alembic migration 003 (mtgonline_cards table)",
|
||||
"Deck CRUD endpoints (list, create, get, update, delete)",
|
||||
"Card management endpoints (add, update, remove, list cards)",
|
||||
"Deck finalize endpoint (DRAFT → FINAL transition)",
|
||||
"Precedent endpoints (list, create, get, use/clone)",
|
||||
"Card search endpoint (POST /decks/search/cards)",
|
||||
"Suggestion endpoints (list, add suggestions)",
|
||||
"Pydantic schemas for all deckbuilding operations"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": 5,
|
||||
"status": "pending",
|
||||
"description": "Testing and deployment",
|
||||
"key_deliverables": ["Migration tests", "API tests", "Docker deployment", "Integration tests"]
|
||||
"description": "Card import with fuzzy matching and deck builder service",
|
||||
"key_deliverables": [
|
||||
"Fuzzy card matching service (python-Levenshtein)",
|
||||
"Card import router (file upload/confirm)",
|
||||
"Deck builder service (precedents, suggestions)",
|
||||
"Integration tests for import and builder features"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tech_stack": {
|
||||
@@ -46,17 +70,23 @@
|
||||
},
|
||||
"architectural_notes": "Dual database setup: mtgonline for app data, mtgdata for MTGJSON card data. Alembic migrations run on container startup. Async SQLAlchemy with asyncpg driver. Card mirrors in mtgo_platform for fast deckbuilding queries. User data API mounted at /api/v1/user-data.",
|
||||
"task_description": "Update HANDOFF.md and ROADMAP.md to reflect user data schema work and consolidate state.json",
|
||||
"current_step": "Phase 3 completed - All API endpoints created with comprehensive documentation",
|
||||
"current_step": "Phase 4 completed - Per-user deck building with card search, precedents, and suggestions fully implemented",
|
||||
"files_created": [
|
||||
"alembic.ini",
|
||||
"alembic/env.py",
|
||||
"alembic/versions/001_initial_user_schema.py",
|
||||
"alembic/versions/002_user_deck_building_tables.py",
|
||||
"alembic/versions/003_mtgonline_cards_table.py",
|
||||
"alembic/versions/__init__.py",
|
||||
"app/models/user_data.py",
|
||||
"app/models/user_deck.py",
|
||||
"app/models/models.py (MtgonlineCard added)",
|
||||
"app/schemas/user_data_schemas.py",
|
||||
"app/schemas/user_deck_schemas.py",
|
||||
"app/routers/user_data.py",
|
||||
"app/routers/decks.py",
|
||||
"scripts/run_migrations.sh",
|
||||
"TEST_PLAN.md",
|
||||
"app/schemas/user_data_schemas.py",
|
||||
"app/routers/user_data.py",
|
||||
"API_DOCUMENTATION.md"
|
||||
],
|
||||
"files_modified": [
|
||||
@@ -72,16 +102,21 @@
|
||||
"Composite unique constraints for card collection uniqueness",
|
||||
"RESTful API design with pagination support",
|
||||
"JWT authentication for all endpoints",
|
||||
"Permission checks for group/network management"
|
||||
"Permission checks for group/network management",
|
||||
"Option B for cross-DB FK: Local card mirror in mtgonline DB (mtgonline_cards)",
|
||||
"Fuzzy matching reserved for card import feature only (handles typos in user input)",
|
||||
"Local card search endpoint for fast deckbuilding queries"
|
||||
],
|
||||
"next_steps": [
|
||||
"Test migration execution in container",
|
||||
"Run API tests against all endpoints",
|
||||
"Add rate limiting for production",
|
||||
"Create integration tests",
|
||||
"Deploy to staging environment"
|
||||
"Deploy to staging environment",
|
||||
"Phase 5: Card import with fuzzy matching (python-Levenshtein)",
|
||||
"Phase 5: Deck builder service for precedents and suggestions"
|
||||
],
|
||||
"blockers": [],
|
||||
"commit_hash": "6b38ef0",
|
||||
"timestamp": "2026-07-23T00:30:06-04:00"
|
||||
"timestamp": "2026-07-24T04:12:00-04:00"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user