feat: add card import feature with fuzzy matching

- Add UserCardImport model (stores imported card names as JSON)
- Create card_import_schemas.py with request/response models
- Create card_import.py router with import/get/delete endpoints
- Add Alembic migration 004 (user_card_imports table)
- Fuzzy matching for card name matching (60% threshold)
- Integration with deckbuilding via imported cards
- Endpoints: GET /api/v1/card-import/status, POST /, DELETE /, GET /summary
This commit is contained in:
2026-07-24 04:47:42 +00:00
parent c23f88cd41
commit 867b7a9c37
9 changed files with 436 additions and 3 deletions
+1
View File
@@ -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
@@ -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')
+2 -2
View File
@@ -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():
+3
View File
@@ -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",
]
+33
View File
@@ -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"<UserCardImport user={self.user_id}>"
+2
View File
@@ -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",
]
+288
View File
@@ -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,
)
@@ -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
+1 -1
View File
@@ -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"
}