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
+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,
)