diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 9a27db0..bf73d66 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -25,6 +25,7 @@ from app.models.user_data import ( NetworkMember, UserPreference, UserActivityLog ) from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion +from app.models.user_card_import import UserCardImport # this is the Alembic Config object config = context.config diff --git a/backend/alembic/versions/004_card_import_table.py b/backend/alembic/versions/004_card_import_table.py new file mode 100644 index 0000000..2870a08 --- /dev/null +++ b/backend/alembic/versions/004_card_import_table.py @@ -0,0 +1,36 @@ +""" +Alembic migration: Create user_card_imports table. + +This migration adds the user_card_imports table which stores +a user's imported card collection as a JSON array of card names. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '004' +down_revision = '003' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create user_card_imports table.""" + op.create_table( + 'user_card_imports', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('card_names_json', sa.Text(), nullable=False), + 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()), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['user_id'], ['mtgonline_users.id'], ondelete='CASCADE'), + sa.UniqueConstraint('user_id', name='uq_user_card_imports_user_id'), + sa.Index('idx_user_card_imports_user', 'user_id'), + ) + + +def downgrade() -> None: + """Drop user_card_imports table.""" + op.drop_table('user_card_imports') diff --git a/backend/app/main.py b/backend/app/main.py index 60e0272..002bb95 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,7 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.settings import get_settings from app.core.database import engine, mtg_engine, async_session, mtg_async_session -from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions, refresh, user_data +from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions, refresh, user_data, card_import from app.services.mtgjson_manager import MTGJSONManager @@ -141,7 +141,7 @@ app.include_router(card_router.router, prefix="/api", tags=["MTG Cards"]) app.include_router(interactions.router, tags=["Card Interactions"]) app.include_router(refresh.router) app.include_router(user_data.router, prefix="/api/v1/user-data", tags=["User Data"]) - +app.include_router(card_import.router, prefix="/api/v1/card-import", tags=["Card Import"]) @app.get("/health", tags=["Health"]) async def health_check(): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5253e90..86a4e2f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,7 @@ from app.models.user_data import ( NetworkMember, UserPreference, UserActivityLog ) from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard, CardSuggestion +from app.models.user_card_import import UserCardImport __all__ = [ "User", @@ -43,4 +44,6 @@ __all__ = [ "DeckPrecedent", "DeckPrecedentCard", "CardSuggestion", + "UserCardCollection", + "UserCardImport", ] diff --git a/backend/app/models/user_card_import.py b/backend/app/models/user_card_import.py new file mode 100644 index 0000000..29b4ea6 --- /dev/null +++ b/backend/app/models/user_card_import.py @@ -0,0 +1,33 @@ +""" +SQLAlchemy ORM model for user card imports. + +Stores a user's imported card collection as a JSON string containing +a list of card names. This is the source data for building decks +from the user's actual card collection. +""" +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, UniqueConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base + + +class UserCardImport(Base): + """ + User's imported card collection. + + Stores a JSON string of card names that the user owns. + Used as the source for building decks from user's actual cards. + """ + __tablename__ = "user_card_imports" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) + card_names_json = Column(Text, nullable=False) # JSON array of card names + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Relationships + user = relationship("User", backref="card_imports") + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 958517a..1cfd8b4 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -13,6 +13,7 @@ from app.routers import admin from app.routers import card_router from app.routers import interactions from app.routers import refresh +from app.routers import card_import __all__ = [ "auth", @@ -24,4 +25,5 @@ __all__ = [ "card_router", "interactions", "refresh", + "card_import", ] diff --git a/backend/app/routers/card_import.py b/backend/app/routers/card_import.py new file mode 100644 index 0000000..6d748cf --- /dev/null +++ b/backend/app/routers/card_import.py @@ -0,0 +1,288 @@ +""" +Card import router endpoints. + +Provides endpoints for importing card collections, viewing import status, +and using imported cards for deckbuilding. This feature allows users to +upload their owned cards as a list, which then informs deckbuilding. +""" +import json +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, update +from sqlalchemy.orm import selectinload +from typing import Optional, List + +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.models import User, MtgonlineCard +from app.models.user_card_import import UserCardImport +from app.schemas.card_import_schemas import ( + CardImportRequest, + CardImportResponse, + CardImportStatusResponse, + CardMatchResult, + CardImportSummary, + MessageResponse, +) + +router = APIRouter() + + +@router.get("/status", response_model=CardImportStatusResponse) +async def get_card_import_status( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + Get current card import status for the current user. + + Returns whether a card import exists, the card count, + and the names of all imported cards. + """ + user_id = int(current_user["user_id"]) + + stmt = select(UserCardImport).where(UserCardImport.user_id == user_id) + result = await db.execute(stmt) + card_import = result.scalar_one_or_none() + + if not card_import: + return CardImportStatusResponse(has_import=False) + + # Parse card names from JSON + try: + card_names = json.loads(card_import.card_names_json) + except (json.JSONDecodeError, TypeError): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Invalid card import data format" + ) + + return CardImportStatusResponse( + has_import=True, + card_count=len(card_names), + card_names=card_names, + last_imported=card_import.updated_at, + ) + + +@router.post("/", response_model=CardImportResponse) +async def import_cards( + request: CardImportRequest, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + Import a card collection for the current user. + + Accepts a list of card names and stores them as the user's + owned card collection. This replaces any existing import. + Uses fuzzy matching to find card IDs in the mtgonline_cards table. + """ + user_id = int(current_user["user_id"]) + + # Normalize card names: strip whitespace, title case + normalized_names = [name.strip().title() for name in request.card_names] + + # Fetch all cards from the mtgonline_cards mirror + stmt = select(MtgonlineCard).options(selectinload(MtgonlineCard.deck_cards)) + result = await db.execute(stmt) + all_cards = result.scalars().all() + + # Build lookup dictionaries + card_by_name = {} # exact title case name -> MtgonlineCard + card_by_lower = {} # lowercase name -> MtgonlineCard (for fuzzy matching) + + for card in all_cards: + if card.name: + # Exact match (title case) + card_by_name[card.name] = card + # Lowercase for case-insensitive matching + card_by_lower[card.name.lower()] = card + + # Match each imported card name to a database card + matched_cards = [] + unmatched_cards = [] + matched_card_names = [] + + for card_name in normalized_names: + # Try exact match first + if card_name in card_by_name: + matched_cards.append(CardMatchResult( + card_id=card_by_name[card_name].id, + card_name=card_name, + matched_name=card_by_name[card_name].name, + match_type="exact", + confidence=1.0, + )) + matched_card_names.append(card_by_name[card_name].name) + continue + + # Try case-insensitive match + if card_name.lower() in card_by_lower: + matched_cards.append(CardMatchResult( + card_id=card_by_lower[card_name.lower()].id, + card_name=card_name, + matched_name=card_by_lower[card_name.lower()].name, + match_type="exact", + confidence=0.95, + )) + matched_card_names.append(card_by_lower[card_name.lower()].name) + continue + + # Try partial match (fuzzy) + best_match = None + best_confidence = 0.0 + for db_name, db_card in card_by_lower.items(): + # Simple partial match: check if one contains the other + if card_name.lower() in db_name or db_name.lower() in card_name.lower(): + # Calculate confidence based on length similarity + min_len = min(len(card_name), len(db_name)) + max_len = max(len(card_name), len(db_name)) + if max_len > 0: + confidence = min_len / max_len + if confidence > best_confidence: + best_confidence = confidence + best_match = db_card + + if best_match and best_confidence >= 0.6: # 60% similarity threshold + matched_cards.append(CardMatchResult( + card_id=best_match.id, + card_name=card_name, + matched_name=best_match.name, + match_type="partial", + confidence=best_confidence, + )) + matched_card_names.append(best_match.name) + else: + unmatched_cards.append(card_name) + + # Create or update the card import + card_names_json = json.dumps(matched_card_names) + + existing = select(UserCardImport).where(UserCardImport.user_id == user_id) + existing_result = await db.execute(existing) + existing_import = existing_result.scalar_one_or_none() + + if existing_import: + # Update existing import + stmt = ( + update(UserCardImport) + .where(UserCardImport.id == existing_import.id) + .values( + card_names_json=card_names_json, + updated_at=func.now(), + ) + ) + await db.execute(stmt) + await db.flush() + + # Fetch updated import + stmt = select(UserCardImport).where(UserCardImport.id == existing_import.id) + result = await db.execute(stmt) + updated_import = result.scalar_one_or_none() + else: + # Create new import + new_import = UserCardImport( + user_id=user_id, + card_names_json=card_names_json, + ) + db.add(new_import) + await db.flush() + + updated_import = new_import + + return CardImportResponse( + message=f"Imported {len(matched_card_names)} cards successfully", + card_count=len(matched_card_names), + card_names=matched_card_names, + imported_at=updated_import.updated_at, + ) + + +@router.delete("/", response_model=MessageResponse) +async def delete_card_import( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """Delete the current user's card import.""" + user_id = int(current_user["user_id"]) + + stmt = select(UserCardImport).where(UserCardImport.user_id == user_id) + result = await db.execute(stmt) + card_import = result.scalar_one_or_none() + + if not card_import: + return MessageResponse(message="No card import found to delete") + + # Delete the import (cascade will handle related data if any) + await db.delete(card_import) + await db.flush() + + return MessageResponse(message="Card import deleted successfully") + + +@router.get("/summary", response_model=CardImportSummary) +async def get_card_import_summary( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + Get a summary of the card import including match results. + + Returns the full match results with confidence scores and + lists of unmatched cards for review. + """ + user_id = int(current_user["user_id"]) + + stmt = select(UserCardImport).where(UserCardImport.user_id == user_id) + result = await db.execute(stmt) + card_import = result.scalar_one_or_none() + + if not card_import: + return CardImportSummary( + total_cards=0, + matched_cards=[], + unmatched_cards=[], + ) + + try: + card_names = json.loads(card_import.card_names_json) + except (json.JSONDecodeError, TypeError): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Invalid card import data format" + ) + + # Fetch all cards for matching + stmt = select(MtgonlineCard) + result = await db.execute(stmt) + all_cards = result.scalars().all() + + # Build lookup + card_by_lower = {card.name.lower(): card for card in all_cards if card.name} + + # Match cards + matched_cards = [] + unmatched_cards = [] + + for card_name in card_names: + normalized = card_name.strip().title() + + if normalized.lower() in card_by_lower: + db_card = card_by_lower[normalized.lower()] + matched_cards.append(CardMatchResult( + card_id=db_card.id, + card_name=normalized, + matched_name=db_card.name, + match_type="exact", + confidence=1.0, + )) + else: + unmatched_cards.append(card_name) + + return CardImportSummary( + total_cards=len(card_names), + matched_cards=matched_cards, + unmatched_cards=unmatched_cards, + import_id=card_import.id, + ) diff --git a/backend/app/schemas/card_import_schemas.py b/backend/app/schemas/card_import_schemas.py new file mode 100644 index 0000000..055db88 --- /dev/null +++ b/backend/app/schemas/card_import_schemas.py @@ -0,0 +1,70 @@ +""" +Pydantic schemas for card import feature. + +Provides request/response models for importing card collections +and using them for deckbuilding. +""" +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from datetime import datetime + + +class CardImportRequest(BaseModel): + """Request body for importing card collection.""" + card_names: List[str] = Field( + ..., + min_length=1, + max_length=10000, + description="List of card names to import", + examples=[["Lightning Bolt", "Shock", "Thoughtseize"]] + ) + + +class CardImportResponse(BaseModel): + """Response after successful card import.""" + message: str + card_count: int + card_names: List[str] + imported_at: datetime + + +class CardImportStatusResponse(BaseModel): + """Response showing current import status.""" + has_import: bool + card_count: Optional[int] = None + card_names: Optional[List[str]] = None + last_imported: Optional[datetime] = None + + +class CardMatchResult(BaseModel): + """Result of matching imported card name to database card.""" + card_id: Optional[int] = None + card_name: str + matched_name: str + match_type: str # 'exact', 'fuzzy', 'partial' + confidence: float # 0.0 to 1.0 + + +class CardImportSummary(BaseModel): + """Summary of card import with match results.""" + total_cards: int + matched_cards: List[CardMatchResult] + unmatched_cards: List[str] + import_id: Optional[int] = None + + +# Generic response models +class MessageResponse(BaseModel): + """Generic message response.""" + message: str + + +class CountResponse(BaseModel): + """Generic count response.""" + count: int + + +class ErrorResponse(BaseModel): + """Error response with details.""" + detail: str + error_code: Optional[str] = None diff --git a/state.json b/state.json index 40df76e..bba9223 100644 --- a/state.json +++ b/state.json @@ -117,6 +117,6 @@ "Phase 5: Deck builder service for precedents and suggestions" ], "blockers": [], - "commit_hash": "6b38ef0", + "commit_hash": "c23f88c", "timestamp": "2026-07-24T04:12:00-04:00" }