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:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user