Complete Phase 2: Card import, deck building, and full API

- Add card import feature with fuzzy matching
- Implement deck CRUD and management endpoints
- Add user data APIs for groups, networks, preferences, activity, replays
- Create comprehensive API documentation (API_DOCUMENTATION.md)
- Add ENDPOINT_AUDIT.md for endpoint verification
- Update documentation (README, ROADMAP, state.json)
- Update architecture blueprint and Cockatrice analysis
- All Phase 2 deliverables complete and documented
This commit is contained in:
2026-07-25 02:56:29 +00:00
parent 351c8a9ba9
commit 1e7c762452
18 changed files with 2048 additions and 560 deletions
+5 -4
View File
@@ -1,4 +1,4 @@
# Models package
"""Models package initialization."""
from app.models.models import User, DecklistFile, DecklistFolder, Room, RoomGameType, Ban, GameLog, AuditLog
from app.models.mtg_models import MtgSet, MtgCard
from app.models.mirror_models import MtgCardMirror, DeckCardLink
@@ -9,7 +9,8 @@ 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
from app.models.card_import_batch import CardImportBatch
from app.models.user_card_import_record import UserCardImportRecord
__all__ = [
"User",
@@ -44,6 +45,6 @@ __all__ = [
"DeckPrecedent",
"DeckPrecedentCard",
"CardSuggestion",
"UserCardCollection",
"UserCardImport",
"CardImportBatch",
"UserCardImportRecord",
]
+56
View File
@@ -0,0 +1,56 @@
"""SQLAlchemy ORM model for card import batches."""
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class CardImportBatch(Base):
"""
Card import batch record.
Tracks a single file import with its status and match results.
"""
__tablename__ = "card_import_batches"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True)
filename = Column(String(255), nullable=False)
file_type = Column(String(10), nullable=False) # xlsx, csv, json, ods
file_size = Column(Integer, nullable=False) # File size in bytes
status = Column(String(20), nullable=False, default="pending", index=True) # pending, processing, completed, failed
total_cards = Column(Integer, default=0)
matched_cards = Column(Integer, default=0)
unmatched_cards = Column(Integer, default=0)
match_results = Column(JSON, nullable=True) # Store match results for later retrieval
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", backref="import_batches")
def __repr__(self) -> str:
return f"<CardImportBatch id={self.id} user={self.user_id} status={self.status}>"
class UserCardImportRecord(Base):
"""
Confirmed user card import record.
Stores the confirmed state of an imported card collection.
"""
__tablename__ = "user_card_imports_confirmed"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True)
batch_id = Column(Integer, ForeignKey("card_import_batches.id", ondelete="CASCADE"), nullable=False, index=True)
is_confirmed = Column(Boolean, nullable=False, default=True)
confirmed_at = Column(DateTime, server_default=func.now())
# Relationships
user = relationship("User", backref="confirmed_imports")
batch = relationship("CardImportBatch", backref="confirmations")
def __repr__(self) -> str:
return f"<UserCardImportRecord id={self.id} user={self.user_id} confirmed={self.is_confirmed}>"
@@ -0,0 +1,27 @@
"""SQLAlchemy ORM model for confirmed card imports."""
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class UserCardImportRecord(Base):
"""
Confirmed user card import record.
Stores the confirmed state of an imported card collection.
"""
__tablename__ = "user_card_imports_confirmed"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("mtgonline_users.id", ondelete="CASCADE"), nullable=False, index=True)
batch_id = Column(Integer, ForeignKey("card_import_batches.id", ondelete="CASCADE"), nullable=False, index=True)
is_confirmed = Column(Boolean, nullable=False, default=True)
confirmed_at = Column(DateTime, server_default=func.now())
# Relationships
user = relationship("User", backref="confirmed_imports")
batch = relationship("CardImportBatch", backref="confirmations")
def __repr__(self) -> str:
return f"<UserCardImportRecord id={self.id} user={self.user_id} confirmed={self.is_confirmed}>"
+309 -229
View File
@@ -1,21 +1,27 @@
"""
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.
Provides endpoints for importing card collections from files,
viewing import status, and confirming imports.
"""
import json
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from typing import Optional, List
from datetime import datetime
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.models.card_import_batch import CardImportBatch
from app.models.user_card_import_record import UserCardImportRecord
from app.models.user_card_collection import UserCardCollection
from app.models.models import MtgonlineCard
from app.models.user_deck import UserDeck, UserDeckCard
from app.services.file_parser import FileParser
from app.services.import_batch_processor import ImportBatchProcessor
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
from app.schemas.card_import_schemas import (
CardImportRequest,
CardImportResponse,
@@ -24,265 +30,339 @@ from app.schemas.card_import_schemas import (
CardImportSummary,
MessageResponse,
)
from app.schemas.user_deck_schemas import DeckCardResponse, DeckCardListResponse
router = APIRouter()
@router.get("/status", response_model=CardImportStatusResponse)
async def get_card_import_status(
@router.post("/import", response_model=CardImportResponse, status_code=status.HTTP_201_CREATED)
async def upload_card_import(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Get current card import status for the current user.
Upload a card import file (XLSX, CSV, JSON, ODS).
Returns whether a card import exists, the card count,
and the names of all imported cards.
Parses the file, performs fuzzy matching, and creates an import batch.
"""
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):
# Validate file type
if not file.filename:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Invalid card import data format"
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must have a filename"
)
file_type = file.filename.split(".")[-1].lower()
supported_types = ["xlsx", "csv", "json", "ods"]
if file_type not in supported_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported file type: {file_type}. Supported types: {supported_types}"
)
# Read file content
content = await file.read()
file_size = len(content)
# Parse file
from pathlib import Path
import tempfile
with tempfile.NamedTemporaryFile(suffix=f".{file_type}", delete=False) as tmp_file:
tmp_file.write(content)
tmp_file_path = Path(tmp_file.name)
try:
card_names = await FileParser.parse_file(tmp_file_path)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to parse file: {str(e)}"
)
if not card_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File contains no card names"
)
# Create import batch
batch = await ImportBatchProcessor.create_batch(
db=db,
user_id=user_id,
filename=file.filename,
file_type=file_type,
file_size=file_size,
card_names=card_names,
)
# Process batch
result = await ImportBatchProcessor.process_batch(
db=db,
batch=batch,
card_names=card_names,
)
return CardImportResponse(
message=f"Import batch created successfully",
batch_id=batch.id,
card_count=len(card_names),
status=result["status"],
imported_at=datetime.utcnow(),
)
@router.get("/import/{import_id}/status", response_model=CardImportStatusResponse)
async def get_import_status(
import_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get the status of an import batch."""
user_id = int(current_user["user_id"])
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
if not batch:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Import batch {import_id} not found"
)
if batch.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
return CardImportStatusResponse(
has_import=True,
card_count=len(card_names),
card_names=card_names,
last_imported=card_import.updated_at,
batch_id=batch.id,
status=batch.status,
card_count=batch.total_cards,
matched_count=batch.matched_cards,
unmatched_count=batch.unmatched_cards,
last_imported=batch.updated_at,
error_message=batch.error_message,
)
@router.post("/", response_model=CardImportResponse)
async def import_cards(
request: CardImportRequest,
@router.get("/import/{import_id}/results", response_model=CardImportSummary)
async def get_import_results(
import_id: int,
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.
"""
"""Get the match results for an import batch."""
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):
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
if not batch:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Invalid card import data format"
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Import batch {import_id} not found"
)
# Fetch all cards for matching
stmt = select(MtgonlineCard)
result = await db.execute(stmt)
all_cards = result.scalars().all()
if batch.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# Build lookup
card_by_lower = {card.name.lower(): card for card in all_cards if card.name}
if batch.status != "completed":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Import batch {import_id} has not completed yet (status: {batch.status})"
)
match_results = batch.match_results or []
# 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()]
for original_name, card_id, matched_name, confidence, match_type in match_results:
if matched_name:
matched_cards.append(CardMatchResult(
card_id=db_card.id,
card_name=normalized,
matched_name=db_card.name,
match_type="exact",
confidence=1.0,
card_id=card_id,
card_name=original_name,
matched_name=matched_name,
match_type=match_type,
confidence=confidence,
))
else:
unmatched_cards.append(card_name)
unmatched_cards.append(original_name)
return CardImportSummary(
total_cards=len(card_names),
total_cards=len(match_results),
matched_cards=matched_cards,
unmatched_cards=unmatched_cards,
import_id=card_import.id,
import_id=batch.id,
)
@router.post("/import/{import_id}/confirm", response_model=MessageResponse)
async def confirm_import(
import_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Confirm an import batch and save to user's card collection."""
user_id = int(current_user["user_id"])
batch = await ImportBatchProcessor.get_batch_status(db, import_id)
if not batch:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Import batch {import_id} not found"
)
if batch.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
if batch.status != "completed":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Import batch {import_id} has not completed yet (status: {batch.status})"
)
# Confirm batch
try:
await ImportBatchProcessor.confirm_batch(db, import_id, user_id)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# Save matched cards to user's collection
match_results = batch.match_results or []
saved_count = 0
for original_name, card_id, matched_name, confidence, match_type in match_results:
if card_id and match_type in ["exact", "high_confidence"]:
# Check if already in collection
stmt = select(UserCardCollection).where(
UserCardCollection.user_id == user_id,
UserCardCollection.card_id == card_id,
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
existing.quantity += 1
else:
new_collection = UserCardCollection(
user_id=user_id,
card_id=card_id,
quantity=1,
)
db.add(new_collection)
saved_count += 1
await db.flush()
return MessageResponse(
message=f"Import confirmed. {saved_count} cards added to your collection."
)
@router.get("/user/cards", response_model=List[Dict[str, Any]])
async def get_user_cards(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List user's imported cards."""
user_id = int(current_user["user_id"])
stmt = select(UserCardCollection).where(UserCardCollection.user_id == user_id).order_by(UserCardCollection.created_at.desc())
result = await db.execute(stmt)
collections = result.scalars().all()
# Fetch card details
card_ids = [c.card_id for c in collections]
card_details = {}
if card_ids:
card_stmt = select(MtgonlineCard).where(MtgonlineCard.id.in_(card_ids))
card_result = await db.execute(card_stmt)
for card in card_result.scalars().all():
card_details[card.id] = card
result_list = []
for collection in collections:
card = card_details.get(collection.card_id)
result_list.append({
"collection_id": collection.id,
"card_id": collection.card_id,
"card_name": card.name if card else f"Card#{collection.card_id}",
"card_type_line": card.type_line if card else "",
"quantity": collection.quantity,
"condition": collection.condition,
"language": collection.language,
"is_foil": collection.is_foil,
"is_alt_art": collection.is_alt_art,
"acquired_date": collection.acquired_date,
})
return result_list
@router.delete("/user/cards/{card_import_id}", response_model=MessageResponse)
async def delete_user_card(
card_import_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Remove a card from user's collection."""
user_id = int(current_user["user_id"])
stmt = select(UserCardCollection).where(
UserCardCollection.id == card_import_id,
UserCardCollection.user_id == user_id,
)
result = await db.execute(stmt)
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Card not found in your collection"
)
await db.delete(collection)
await db.flush()
return MessageResponse(message="Card removed from your collection")
@router.get("/user/decks", response_model=List[Dict[str, Any]])
async def get_user_decks(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List user's decks."""
user_id = int(current_user["user_id"])
stmt = select(UserDeck).where(UserDeck.user_id == user_id).order_by(UserDeck.updated_at.desc())
result = await db.execute(stmt)
decks = result.scalars().all()
result_list = []
for deck in decks:
result_list.append({
"deck_id": deck.id,
"name": deck.name,
"status": deck.status,
"format": deck.format,
"folder_id": deck.folder_id,
"notes": deck.notes,
"is_precedent": deck.is_precedent,
"created_at": deck.created_at,
"updated_at": deck.updated_at,
})
return result_list
+60 -145
View File
@@ -2,48 +2,55 @@
Card search router for MTG card database.
Provides endpoints for searching and retrieving MTG card data
from the MTG PostgreSQL database with Redis caching.
with filters for type, set, and color.
"""
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import mtg_get_db
from app.core.redis_client import cache_get, cache_set
from app.services.card_database import (
search_cards,
get_card_by_name,
get_cards_by_set,
get_card_types,
get_card_rarities,
get_sets,
get_set_by_code,
get_card_statistics,
)
from app.services.card_search_service import CardSearchService
from app.services.deck_suggestion_service import DeckSuggestionService
from app.models.user_deck import UserDeck
from app.schemas.card_search_schemas import CardSearchResponse, CardResponse, SetResponse, CardTypeResponse
router = APIRouter(prefix="/mtg/cards", tags=["MTG Cards"])
router = APIRouter(prefix="/api/cards", tags=["Card Search"])
@router.get("/search")
@router.get("/search", response_model=CardSearchResponse)
async def search_cards_endpoint(
q: str = Query(..., min_length=1, description="Search query"),
card_type: Optional[str] = Query(None, description="Filter by card type"),
set_code: Optional[str] = Query(None, description="Filter by set code"),
color: Optional[str] = Query(None, description="Filter by color (e.g., WU, BR)"),
limit: int = Query(100, ge=1, le=500, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(mtg_get_db),
):
"""
Search cards by name, type, or mana cost.
Search cards with filters.
Uses Redis cache to improve performance for repeated searches.
Supports filtering by type, set, and color in addition to name search.
"""
cache_key = f"card_search:{q}:{limit}:{offset}"
cache_key = f"card_search:{q}:{card_type}:{set_code}:{color}:{limit}:{offset}"
# Check cache first
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
# Query database
results = await search_cards(q, db, limit, offset)
# Search cards
results = await CardSearchService.search_cards(
db=db,
query=q,
card_type=card_type,
set_code=set_code,
color=color,
limit=limit,
offset=offset,
)
# Cache results for 5 minutes
await cache_set(cache_key, str(results), ttl=300)
@@ -51,70 +58,23 @@ async def search_cards_endpoint(
return {"cached": False, "results": results}
@router.get("/sets")
async def get_sets_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all sets.
"""
cache_key = "all_sets:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
sets = await get_sets(db)
# Cache for 1 hour
await cache_set(cache_key, str(sets), ttl=3600)
return {"cached": False, "results": sets}
@router.get("/sets/{set_code}")
async def get_set_endpoint(
set_code: str,
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get a specific set by code.
"""
cache_key = f"set_by_code:{set_code}"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
mtg_set = await get_set_by_code(set_code, db)
if not mtg_set:
raise HTTPException(status_code=404, detail="Set not found")
# Cache for 1 hour
await cache_set(cache_key, str(mtg_set), ttl=3600)
return {"cached": False, "results": mtg_set}
@router.get("/{card_name}")
@router.get("/{card_id}", response_model=CardResponse)
async def get_card_endpoint(
card_name: str,
set_code: str | None = Query(None, description="Filter by set code"),
card_id: int,
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get a specific card by name.
Optional set_code filter to get a specific printing.
Get a specific card by ID.
"""
cache_key = f"card_by_name:{card_name}:{set_code or 'all'}"
cache_key = f"card_by_id:{card_id}"
# Check cache first
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
card = await get_card_by_name(card_name, db, set_code)
# Get card
card = await CardSearchService.get_card_by_id(db, card_id)
if not card:
raise HTTPException(status_code=404, detail="Card not found")
@@ -125,31 +85,28 @@ async def get_card_endpoint(
return {"cached": False, "results": card}
@router.get("/set/{set_code}")
async def get_cards_by_set_endpoint(
set_code: str,
limit: int = Query(1000, ge=1, le=5000, description="Maximum results"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
@router.get("/sets", response_model=List[SetResponse])
async def get_sets_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all cards in a specific set.
Get all available sets.
"""
cache_key = f"set_cards:{set_code}:{limit}:{offset}"
cache_key = "all_sets:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
results = await get_cards_by_set(set_code, db, limit, offset)
sets = await CardSearchService.get_sets(db)
# Cache for 15 minutes
await cache_set(cache_key, str(results), ttl=900)
# Cache for 1 hour
await cache_set(cache_key, str(sets), ttl=3600)
return {"cached": False, "results": results}
return {"cached": False, "results": sets}
@router.get("/types")
@router.get("/types", response_model=List[CardTypeResponse])
async def get_card_types_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
@@ -162,7 +119,7 @@ async def get_card_types_endpoint(
if cached:
return {"cached": True, "results": cached}
types = await get_card_types(db)
types = await CardSearchService.get_card_types(db)
# Cache for 30 minutes
await cache_set(cache_key, str(types), ttl=1800)
@@ -170,7 +127,7 @@ async def get_card_types_endpoint(
return {"cached": False, "results": types}
@router.get("/rarities")
@router.get("/rarities", response_model=List[str])
async def get_card_rarities_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
@@ -183,7 +140,7 @@ async def get_card_rarities_endpoint(
if cached:
return {"cached": True, "results": cached}
rarities = await get_card_rarities(db)
rarities = await CardSearchService.get_card_rarities(db)
# Cache for 30 minutes
await cache_set(cache_key, str(rarities), ttl=1800)
@@ -191,68 +148,26 @@ async def get_card_rarities_endpoint(
return {"cached": False, "results": rarities}
@router.get("/sets")
async def get_sets_endpoint(
@router.get("/suggest", response_model=List[Dict[str, Any]])
async def suggest_cards_endpoint(
deck_id: int = Query(..., description="Deck ID to suggest cards for"),
limit: int = Query(20, ge=1, le=100, description="Maximum suggestions"),
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get all sets.
Suggest similar cards for a deck.
Matches by: same type, same color, same set, same mana cost,
and cards often paired in existing user decks.
"""
cache_key = "all_sets:all"
# Verify deck exists
stmt = select(UserDeck).where(UserDeck.id == deck_id)
result = await db.execute(stmt)
deck = result.scalar_one_or_none()
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
sets = await get_sets(db)
suggestions = await DeckSuggestionService.suggest_cards(db, deck_id, limit)
# Cache for 1 hour
await cache_set(cache_key, str(sets), ttl=3600)
return {"cached": False, "results": sets}
@router.get("/sets/{set_code}")
async def get_set_endpoint(
set_code: str,
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get a specific set by code.
"""
cache_key = f"set_by_code:{set_code}"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
mtg_set = await get_set_by_code(set_code, db)
if not mtg_set:
raise HTTPException(status_code=404, detail="Set not found")
# Cache for 1 hour
await cache_set(cache_key, str(mtg_set), ttl=3600)
return {"cached": False, "results": mtg_set}
@router.get("/statistics")
async def get_card_statistics_endpoint(
db: AsyncSession = Depends(mtg_get_db),
):
"""
Get overall card database statistics.
"""
cache_key = "card_statistics:all"
cached = await cache_get(cache_key)
if cached:
return {"cached": True, "results": cached}
stats = await get_card_statistics(db)
# Cache for 1 hour
await cache_set(cache_key, str(stats), ttl=3600)
return {"cached": False, "results": stats}
return suggestions
+109
View File
@@ -0,0 +1,109 @@
"""Pydantic schemas for card search and import features."""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from datetime import datetime
# ===== Card Search Schemas =====
class CardResponse(BaseModel):
"""Card response with details."""
id: int
name: str
mana_cost: Optional[str] = None
type_line: Optional[str] = None
oracle_text: Optional[str] = None
power: Optional[str] = None
toughness: Optional[str] = None
rarity: Optional[str] = None
layout: Optional[str] = None
colors: Optional[str] = None
set_code: Optional[str] = None
set_name: Optional[str] = None
identifiers: Optional[Dict[str, Any]] = None
images: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
class SetResponse(BaseModel):
"""Set response."""
id: int
name: str
code: str
release_date: Optional[datetime] = None
card_count: Optional[int] = None
class Config:
from_attributes = True
class CardTypeResponse(BaseModel):
"""Card type response."""
type: str
class CardSearchResponse(BaseModel):
"""Card search response."""
cards: List[Dict[str, Any]]
total: int
page: int
page_size: int
total_pages: int
# ===== Card Import Schemas =====
class CardImportResponse(BaseModel):
"""Response after successful card import upload."""
message: str
batch_id: int
card_count: int
status: str
imported_at: datetime
class CardImportStatusResponse(BaseModel):
"""Response showing current import status."""
has_import: bool
batch_id: Optional[int] = None
status: Optional[str] = None
card_count: Optional[int] = None
matched_count: Optional[int] = None
unmatched_count: Optional[int] = None
last_imported: Optional[datetime] = None
error_message: Optional[str] = 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', 'high_confidence', 'low_confidence'
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
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
+27 -11
View File
@@ -1,16 +1,20 @@
"""Services package."""
from app.services.card_database import (
search_cards,
get_card_by_name,
get_cards_by_set,
get_card_types,
get_card_rarities,
get_sets,
get_set_by_code,
get_card_statistics,
)
"""Services package initialization."""
from app.services.deck_parser import DeckParser
from app.services.card_database import search_cards, get_card_by_name, get_cards_by_set, get_card_types, get_card_rarities, get_sets, get_set_by_code, get_card_statistics
from app.services.card_mirror_service import CardMirrorService
from app.services.mtgjson_manager import MTGJSONManager, get_manager
from app.services.mtgjson_downloader import MTGJSONDownloader
from app.services.mtgjson_loader import MTGJSONLoader
from app.services.mtgjson_uploader import MTGJSONUploader
from app.services.file_parser import FileParser
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
from app.services.import_batch_processor import ImportBatchProcessor
from app.services.deck_manager import DeckManager
from app.services.card_search_service import CardSearchService
from app.services.deck_suggestion_service import DeckSuggestionService
__all__ = [
"DeckParser",
"search_cards",
"get_card_by_name",
"get_cards_by_set",
@@ -19,4 +23,16 @@ __all__ = [
"get_sets",
"get_set_by_code",
"get_card_statistics",
"CardMirrorService",
"MTGJSONManager",
"get_manager",
"MTGJSONDownloader",
"MTGJSONLoader",
"MTGJSONUploader",
"FileParser",
"FuzzyCardMatcher",
"ImportBatchProcessor",
"DeckManager",
"CardSearchService",
"DeckSuggestionService",
]
+190
View File
@@ -0,0 +1,190 @@
"""Card search service with filters."""
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, and_
from sqlalchemy.orm import selectinload
from app.models.mtg_models import MtgCard, MtgSet
from app.models.mirror_models import MtgCardMirror
class CardSearchService:
"""Card search service with filters."""
@staticmethod
async def search_cards(
db: AsyncSession,
query: str,
card_type: Optional[str] = None,
set_code: Optional[str] = None,
color: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> Dict[str, Any]:
"""
Search cards with filters.
Args:
db: Database session
query: Search query (name, type, mana cost)
card_type: Filter by card type
set_code: Filter by set code
color: Filter by card color
limit: Maximum results
offset: Number of results to skip
Returns:
Dictionary with search results and metadata
"""
# Build conditions
conditions = [
or_(
MtgCard.name.ilike(f"%{query}%"),
MtgCard.type_line.ilike(f"%{query}%"),
MtgCard.mana_cost.ilike(f"%{query}%"),
)
]
if card_type:
conditions.append(MtgCard.type_line.ilike(f"%{card_type}%"))
if set_code:
conditions.append(MtgCard.set_code == set_code)
if color:
# Parse color string (e.g., "WU" for white-blue)
colors = [c.strip() for c in color.upper().split(",")]
for c in colors:
if c in ["W", "U", "B", "R", "G"]:
conditions.append(MtgCard.colors.ilike(f"%{c}%"))
# Count total results
count_stmt = select(MtgCard).where(*conditions)
total_result = await db.execute(count_stmt)
total = len(total_result.scalars().all())
# Fetch results with pagination
stmt = select(MtgCard).where(*conditions).offset(offset).limit(limit)
result = await db.execute(stmt)
cards = result.scalars().all()
# Format results
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,
"colors": card.colors,
"set_code": card.set_code,
"set_name": card.set_name,
}
card_list.append(card_data)
return {
"cards": card_list,
"total": total,
"page": offset // limit + 1,
"page_size": limit,
"total_pages": (total + limit - 1) // limit,
}
@staticmethod
async def get_card_by_id(db: AsyncSession, card_id: int) -> Optional[Dict[str, Any]]:
"""
Get a card by its ID.
Args:
db: Database session
card_id: Card ID
Returns:
Card data dictionary or None
"""
stmt = select(MtgCard).where(MtgCard.id == card_id)
result = await db.execute(stmt)
card = result.scalar_one_or_none()
if not card:
return None
return {
"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,
"colors": card.colors,
"set_code": card.set_code,
"set_name": card.set_name,
"identifiers": card.identifiers,
"images": card.images,
}
@staticmethod
async def get_sets(db: AsyncSession) -> List[Dict[str, Any]]:
"""
Get all available sets.
Args:
db: Database session
Returns:
List of set data dictionaries
"""
stmt = select(MtgSet).order_by(MtgSet.name)
result = await db.execute(stmt)
sets = result.scalars().all()
return [
{
"id": s.id,
"name": s.name,
"code": s.code,
"release_date": s.release_date,
"card_count": s.card_count,
}
for s in sets
]
@staticmethod
async def get_card_types(db: AsyncSession) -> List[str]:
"""
Get all unique card types.
Args:
db: Database session
Returns:
List of unique card types
"""
stmt = select(MtgCard.type_line).distinct()
result = await db.execute(stmt)
types = result.scalars().all()
return list(types)
@staticmethod
async def get_card_rarities(db: AsyncSession) -> List[str]:
"""
Get all unique card rarities.
Args:
db: Database session
Returns:
List of unique rarities
"""
stmt = select(MtgCard.rarity).distinct()
result = await db.execute(stmt)
rarities = result.scalars().all()
return list(rarities)
+246
View File
@@ -0,0 +1,246 @@
"""Deck manager service."""
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
from app.models.user_deck import UserDeck, UserDeckCard, DeckPrecedent, DeckPrecedentCard
from app.models.models import MtgonlineCard
class DeckManager:
"""Deck manager service."""
@staticmethod
async def create_deck(
db: AsyncSession,
user_id: int,
name: str,
folder_id: Optional[int] = None,
format: str = "standard",
notes: Optional[str] = None,
is_precedent: bool = False,
precedent_name: Optional[str] = None,
) -> UserDeck:
"""Create a new deck."""
deck = UserDeck(
user_id=user_id,
name=name,
folder_id=folder_id,
format=format,
notes=notes,
is_precedent=is_precedent,
precedent_name=precedent_name,
)
db.add(deck)
await db.flush()
return deck
@staticmethod
async def get_deck(db: AsyncSession, deck_id: int, user_id: int) -> Optional[UserDeck]:
"""Get a deck by ID."""
stmt = select(UserDeck).where(UserDeck.id == deck_id, UserDeck.user_id == user_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def list_decks(
db: AsyncSession,
user_id: int,
status_filter: Optional[str] = None,
folder_id: Optional[int] = None,
is_precedent: Optional[bool] = None,
page: int = 1,
page_size: int = 50,
) -> List[UserDeck]:
"""List user's decks with filtering."""
conditions = [UserDeck.user_id == user_id]
if status_filter:
conditions.append(UserDeck.status == status_filter)
if folder_id:
conditions.append(UserDeck.folder_id == folder_id)
if is_precedent is not None:
conditions.append(UserDeck.is_precedent == is_precedent)
offset = (page - 1) * page_size
stmt = select(UserDeck).where(*conditions).order_by(UserDeck.updated_at.desc()).offset(offset).limit(page_size)
result = await db.execute(stmt)
return result.scalars().all()
@staticmethod
async def update_deck(
db: AsyncSession,
deck_id: int,
user_id: int,
name: Optional[str] = None,
folder_id: Optional[int] = None,
format: Optional[str] = None,
notes: Optional[str] = None,
) -> Optional[UserDeck]:
"""Update a deck."""
deck = await DeckManager.get_deck(db, deck_id, user_id)
if not deck:
return None
if deck.status == "FINAL":
raise ValueError("Cannot modify a finalized deck")
if name:
deck.name = name
if folder_id is not None:
deck.folder_id = folder_id
if format:
deck.format = format
if notes is not None:
deck.notes = notes
await db.flush()
return deck
@staticmethod
async def delete_deck(db: AsyncSession, deck_id: int, user_id: int) -> bool:
"""Delete a deck."""
deck = await DeckManager.get_deck(db, deck_id, user_id)
if not deck:
return False
await db.execute(delete(UserDeck).where(UserDeck.id == deck_id))
await db.flush()
return True
@staticmethod
async def finalize_deck(db: AsyncSession, deck_id: int, user_id: int) -> Optional[UserDeck]:
"""Transition a deck from DRAFT to FINAL status."""
deck = await DeckManager.get_deck(db, deck_id, user_id)
if not deck:
return None
if deck.status == "FINAL":
raise ValueError("Deck is already finalized")
# Check deck has cards
card_count_stmt = select(func.count()).select_from(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
card_count_result = await db.execute(card_count_stmt)
card_count = card_count_result.scalar() or 0
if card_count == 0:
raise ValueError("Cannot finalize an empty deck")
deck.status = "FINAL"
await db.flush()
return deck
@staticmethod
async def add_card_to_deck(
db: AsyncSession,
deck_id: int,
card_id: int,
quantity: int = 1,
zone: str = "main",
position: Optional[int] = None,
) -> UserDeckCard:
"""Add a card to a deck."""
deck_card = UserDeckCard(
deck_id=deck_id,
card_id=card_id,
quantity=quantity,
zone=zone,
position=position,
)
db.add(deck_card)
await db.flush()
return deck_card
@staticmethod
async def get_deck_cards(db: AsyncSession, deck_id: int, zone: Optional[str] = None) -> List[UserDeckCard]:
"""Get cards in a deck."""
conditions = [UserDeckCard.deck_id == deck_id]
if zone:
conditions.append(UserDeckCard.zone == zone)
stmt = select(UserDeckCard).where(*conditions).order_by(UserDeckCard.id)
result = await db.execute(stmt)
return result.scalars().all()
@staticmethod
async def update_deck_card(
db: AsyncSession,
deck_card_id: int,
quantity: Optional[int] = None,
zone: Optional[str] = None,
position: Optional[int] = None,
) -> Optional[UserDeckCard]:
"""Update a card in a deck."""
stmt = select(UserDeckCard).where(UserDeckCard.id == deck_card_id)
result = await db.execute(stmt)
deck_card = result.scalar_one_or_none()
if not deck_card:
return None
if quantity is not None:
deck_card.quantity = quantity
if zone:
deck_card.zone = zone
if position is not None:
deck_card.position = position
await db.flush()
return deck_card
@staticmethod
async def remove_card_from_deck(db: AsyncSession, deck_card_id: int) -> bool:
"""Remove a card from a deck."""
stmt = select(UserDeckCard).where(UserDeckCard.id == deck_card_id)
result = await db.execute(stmt)
deck_card = result.scalar_one_or_none()
if not deck_card:
return False
await db.execute(delete(UserDeckCard).where(UserDeckCard.id == deck_card_id))
await db.flush()
return True
@staticmethod
async def clone_precedent(
db: AsyncSession,
precedent_id: int,
user_id: int,
name: Optional[str] = None,
) -> UserDeck:
"""Clone a precedent into a new deck."""
# 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 ValueError(f"Precedent {precedent_id} not found")
# Create new deck
new_name = name or f"Copy of {precedent.name}"
new_deck = UserDeck(
user_id=user_id,
name=new_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 new_deck
@@ -0,0 +1,209 @@
"""Deck suggestion service."""
from typing import List, Dict, Any, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, and_, func, case
from sqlalchemy.orm import selectinload
from app.models.user_deck import UserDeck, UserDeckCard, CardSuggestion
from app.models.mtg_models import MtgCard
from app.models.mirror_models import MtgCardMirror
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
class DeckSuggestionService:
"""Deck suggestion service."""
@staticmethod
async def suggest_cards(
db: AsyncSession,
deck_id: int,
limit: int = 20,
) -> List[Dict[str, Any]]:
"""
Suggest similar cards for a deck.
Args:
db: Database session
deck_id: Deck ID to suggest cards for
limit: Maximum number of suggestions
Returns:
List of suggested card data dictionaries
"""
# Get deck cards
deck_cards_stmt = select(UserDeckCard).where(UserDeckCard.deck_id == deck_id)
deck_cards_result = await db.execute(deck_cards_stmt)
deck_cards = deck_cards_result.scalars().all()
if not deck_cards:
return []
# Get card IDs in the deck
card_ids = [dc.card_id for dc in deck_cards]
# Get deck card details
card_details_stmt = select(MtgCard).where(MtgCard.id.in_(card_ids))
card_details_result = await db.execute(card_details_stmt)
deck_card_details = card_details_result.scalars().all()
# Analyze deck characteristics
deck_types = set()
deck_colors = set()
deck_sets = set()
deck_mana_costs = []
for card in deck_card_details:
if card.type_line:
# Extract main type (e.g., "Creature" from "Creature — Elf")
main_type = card.type_line.split("")[0].strip()
deck_types.add(main_type)
if card.colors:
deck_colors.update(card.colors)
if card.set_code:
deck_sets.add(card.set_code)
if card.mana_cost:
deck_mana_costs.append(card.mana_cost)
# Search for similar cards
suggestions = []
# Strategy 1: Same type, not already in deck
if deck_types:
type_conditions = [MtgCard.type_line.ilike(f"%{t}%") for t in deck_types]
type_search_stmt = select(MtgCard).where(
or_(*type_conditions),
MtgCard.id.notin_(card_ids),
)
type_results = await db.execute(type_search_stmt)
type_cards = type_results.scalars().all()
for card in type_cards:
suggestions.append({
"card": card,
"reason": "same_type",
"confidence": 0.8,
})
# Strategy 2: Same color, not already in deck
if deck_colors:
color_conditions = []
for color in deck_colors:
color_conditions.append(MtgCard.colors.ilike(f"%{color}%"))
color_search_stmt = select(MtgCard).where(
or_(*color_conditions),
MtgCard.id.notin_(card_ids),
)
color_results = await db.execute(color_search_stmt)
color_cards = color_results.scalars().all()
for card in color_cards:
# Check if already added
if not any(s["card"].id == card.id for s in suggestions):
suggestions.append({
"card": card,
"reason": "same_color",
"confidence": 0.7,
})
# Strategy 3: Same set, not already in deck
if deck_sets:
set_search_stmt = select(MtgCard).where(
MtgCard.set_code.in_(list(deck_sets)),
MtgCard.id.notin_(card_ids),
)
set_results = await db.execute(set_search_stmt)
set_cards = set_results.scalars().all()
for card in set_cards:
# Check if already added
if not any(s["card"].id == card.id for s in suggestions):
suggestions.append({
"card": card,
"reason": "same_set",
"confidence": 0.6,
})
# Sort by confidence and limit results
suggestions.sort(key=lambda x: x["confidence"], reverse=True)
suggestions = suggestions[:limit]
# Format results
result = []
for suggestion in suggestions:
card = suggestion["card"]
result.append({
"card_id": card.id,
"name": card.name,
"mana_cost": card.mana_cost,
"type_line": card.type_line,
"colors": card.colors,
"reason": suggestion["reason"],
"confidence": suggestion["confidence"],
})
return result
@staticmethod
async def add_suggestion(
db: AsyncSession,
deck_id: int,
card_id: int,
source_card_id: Optional[int] = None,
suggestion_type: str = "SIMILAR",
confidence: Optional[float] = None,
notes: Optional[str] = None,
) -> CardSuggestion:
"""
Add a card suggestion to a deck.
Args:
db: Database session
deck_id: Deck ID
card_id: Card ID to suggest
source_card_id: Source card ID that triggered the suggestion
suggestion_type: Type of suggestion
confidence: Confidence score
notes: Additional notes
Returns:
Created CardSuggestion record
"""
suggestion = CardSuggestion(
deck_id=deck_id,
card_id=card_id,
source_card_id=source_card_id,
suggestion_type=suggestion_type,
confidence=confidence,
notes=notes,
)
db.add(suggestion)
await db.flush()
return suggestion
@staticmethod
async def get_deck_suggestions(
db: AsyncSession,
deck_id: int,
suggestion_type: Optional[str] = None,
) -> List[CardSuggestion]:
"""
Get suggestions for a deck.
Args:
db: Database session
deck_id: Deck ID
suggestion_type: Filter by suggestion type
Returns:
List of CardSuggestion records
"""
conditions = [CardSuggestion.deck_id == deck_id]
if suggestion_type:
conditions.append(CardSuggestion.suggestion_type == suggestion_type)
stmt = select(CardSuggestion).where(*conditions).order_by(CardSuggestion.created_at.desc())
result = await db.execute(stmt)
return result.scalars().all()
+108
View File
@@ -0,0 +1,108 @@
"""File parser service for card import."""
import csv
import json
from typing import List, Union
from pathlib import Path
import openpyxl
import pandas as pd
class FileParser:
"""Parse various file formats for card import."""
SUPPORTED_FORMATS = ['xlsx', 'csv', 'json', 'ods']
@staticmethod
async def parse_file(file_path: Path) -> List[str]:
"""
Parse a file and extract card names.
Args:
file_path: Path to the file to parse
Returns:
List of card names extracted from the file
Raises:
ValueError: If file format is not supported
FileNotFoundError: If file does not exist
Exception: If file cannot be parsed
"""
file_type = file_path.suffix.lower().lstrip('.')
if file_type not in FileParser.SUPPORTED_FORMATS:
raise ValueError(f"Unsupported file format: {file_type}. Supported formats: {FileParser.SUPPORTED_FORMATS}")
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
if file_type == 'csv':
return FileParser._parse_csv(file_path)
elif file_type == 'json':
return FileParser._parse_json(file_path)
elif file_type == 'xlsx':
return FileParser._parse_xlsx(file_path)
elif file_type == 'ods':
return FileParser._parse_ods(file_path)
@staticmethod
def _parse_csv(file_path: Path) -> List[str]:
"""Parse CSV file and extract card names."""
card_names = []
with open(file_path, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
for row in reader:
# Take first non-empty column as card name
for cell in row:
cell = cell.strip()
if cell:
card_names.append(cell)
break
return card_names
@staticmethod
def _parse_json(file_path: Path) -> List[str]:
"""Parse JSON file and extract card names."""
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, list):
return [str(item).strip() for item in data if str(item).strip()]
elif isinstance(data, dict):
# Try common keys
for key in ['cards', 'card_names', 'cards_list', 'list']:
if key in data and isinstance(data[key], list):
return [str(item).strip() for item in data[key] if str(item).strip()]
# If no common key found, try first list value
for value in data.values():
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
raise ValueError("Invalid JSON format: expected list or dict with card names")
@staticmethod
def _parse_xlsx(file_path: Path) -> List[str]:
"""Parse XLSX file and extract card names from first column."""
card_names = []
try:
workbook = openpyxl.load_workbook(file_path, read_only=True)
worksheet = workbook.active
for row in worksheet.iter_rows(values_only=True):
if row and row[0]:
cell_value = str(row[0]).strip()
if cell_value:
card_names.append(cell_value)
finally:
if 'workbook' in locals():
workbook.close()
return card_names
@staticmethod
def _parse_ods(file_path: Path) -> List[str]:
"""Parse ODS file and extract card names from first column."""
try:
df = pd.read_excel(file_path, engine='odf')
card_names = df.iloc[:, 0].dropna().astype(str).str.strip().tolist()
return [name for name in card_names if name]
except ImportError:
raise ImportError("pandas with odf engine required for ODS parsing. Install with: pip install pandas odfpy")
+170
View File
@@ -0,0 +1,170 @@
"""Fuzzy card matching service."""
from typing import List, Tuple, Optional
from thefuzz import fuzz
class FuzzyCardMatcher:
"""Fuzzy matching service for card names."""
# Thresholds
EXACT_MATCH_THRESHOLD = 100
AUTO_ACCEPT_THRESHOLD = 85 # Auto-accept matches above this
MANUAL_REVIEW_THRESHOLD = 70 # Flag for manual review below this
MIN_MATCH_THRESHOLD = 60 # Minimum similarity to consider a match
@staticmethod
def normalize_card_name(name: str) -> str:
"""
Normalize a card name for matching.
Args:
name: Raw card name
Returns:
Normalized card name
"""
# Remove extra whitespace
normalized = ' '.join(name.split())
# Convert to lowercase for matching
return normalized.lower()
@staticmethod
def exact_match(name: str, card_name: str) -> bool:
"""Check if two card names match exactly."""
return FuzzyCardMatcher.normalize_card_name(name) == FuzzyCardMatcher.normalize_card_name(card_name)
@staticmethod
def fuzzy_match(name: str, card_name: str) -> float:
"""
Calculate fuzzy match score between two card names.
Args:
name: First card name
card_name: Second card name
Returns:
Similarity score between 0.0 and 100.0
"""
normalized_name = FuzzyCardMatcher.normalize_card_name(name)
normalized_card = FuzzyCardMatcher.normalize_card_name(card_name)
return fuzz.token_sort_ratio(normalized_name, normalized_card)
@staticmethod
def find_best_match(
card_name: str,
candidate_names: List[str],
threshold: float = MANUAL_REVIEW_THRESHOLD
) -> Tuple[Optional[str], float, str]:
"""
Find the best matching card name from candidates.
Args:
card_name: Name to match
candidate_names: List of candidate card names
threshold: Minimum similarity threshold
Returns:
Tuple of (matched_name, confidence, match_type)
- matched_name: Best matching card name or None
- confidence: Match confidence (0.0 to 1.0)
- match_type: 'exact', 'high_confidence', 'low_confidence', or 'no_match'
"""
if not candidate_names:
return None, 0.0, 'no_match'
# Check for exact match first
for candidate in candidate_names:
if FuzzyCardMatcher.exact_match(card_name, candidate):
return candidate, 1.0, 'exact'
# Use fuzzy matching
normalized_name = FuzzyCardMatcher.normalize_card_name(card_name)
# Find best match using token sort ratio
best_match = None
best_score = 0.0
for candidate in candidate_names:
score = fuzz.token_sort_ratio(normalized_name, FuzzyCardMatcher.normalize_card_name(candidate))
if score > best_score:
best_score = score
best_match = candidate
if best_match and best_score >= threshold:
confidence = best_score / 100.0
if best_score >= FuzzyCardMatcher.AUTO_ACCEPT_THRESHOLD:
match_type = 'high_confidence'
else:
match_type = 'low_confidence'
return best_match, confidence, match_type
return None, 0.0, 'no_match'
@staticmethod
def batch_match(
card_names: List[str],
candidate_names: List[str],
threshold: float = MANUAL_REVIEW_THRESHOLD
) -> List[Tuple[str, Optional[str], float, str]]:
"""
Perform batch fuzzy matching.
Args:
card_names: List of card names to match
candidate_names: List of candidate card names
threshold: Minimum similarity threshold
Returns:
List of tuples: (original_name, matched_name, confidence, match_type)
"""
results = []
for card_name in card_names:
matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match(
card_name, candidate_names, threshold
)
results.append((card_name, matched_name, confidence, match_type))
return results
@staticmethod
def batch_match_with_database(
card_names: List[str],
db_session,
mtgonline_card_model,
threshold: float = MANUAL_REVIEW_THRESHOLD
) -> List[Tuple[str, Optional[int], Optional[str], float, str]]:
"""
Perform batch fuzzy matching against database cards.
Args:
card_names: List of card names to match
db_session: Database session
mtgonline_card_model: MtgonlineCard ORM model
threshold: Minimum similarity threshold
Returns:
List of tuples: (original_name, card_id, matched_name, confidence, match_type)
"""
from sqlalchemy import select
# Fetch all cards from database
stmt = select(mtgonline_card_model)
result = db_session.execute(stmt)
db_cards = result.scalars().all()
# Build candidate list and lookup
candidate_names = [card.name for card in db_cards if card.name]
card_lookup = {card.name.lower(): card for card in db_cards if card.name}
results = []
for card_name in card_names:
matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match(
card_name, candidate_names, threshold
)
card_id = None
if matched_name and matched_name.lower() in card_lookup:
card_id = card_lookup[matched_name.lower()].id
results.append((card_name, card_id, matched_name, confidence, match_type))
return results
@@ -0,0 +1,204 @@
"""Import batch processor service."""
import asyncio
from typing import List, Dict, Any, Optional
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, insert, delete
from sqlalchemy.sql import func
from app.models.card_import_batch import CardImportBatch
from app.models.user_card_import_record import UserCardImportRecord
from app.models.user_card_collection import UserCardCollection
from app.models.models import MtgonlineCard
from app.services.fuzzy_card_matcher import FuzzyCardMatcher
class ImportBatchProcessor:
"""Process card import batches."""
@staticmethod
async def create_batch(
db: AsyncSession,
user_id: int,
filename: str,
file_type: str,
file_size: int,
card_names: List[str]
) -> CardImportBatch:
"""Create a new import batch."""
batch = CardImportBatch(
user_id=user_id,
filename=filename,
file_type=file_type,
file_size=file_size,
status="pending",
total_cards=len(card_names),
)
db.add(batch)
await db.flush()
return batch
@staticmethod
async def process_batch(
db: AsyncSession,
batch: CardImportBatch,
card_names: List[str],
threshold: float = FuzzyCardMatcher.MANUAL_REVIEW_THRESHOLD
) -> Dict[str, Any]:
"""
Process an import batch.
Args:
db: Database session
batch: Import batch to process
card_names: List of card names from the file
threshold: Minimum similarity threshold for matching
Returns:
Dictionary with processing results
"""
# Update status to processing
batch.status = "processing"
await db.flush()
try:
# Fetch all cards from database
stmt = select(MtgonlineCard)
result = await db.execute(stmt)
db_cards = result.scalars().all()
# Build candidate list
candidate_names = [card.name for card in db_cards if card.name]
# Perform batch matching
match_results = FuzzyCardMatcher.batch_match_with_database(
card_names=card_names,
db_session=db,
mtgonline_card_model=MtgonlineCard,
threshold=threshold
)
# Count matches
matched_count = sum(1 for _, _, matched_name, _, _ in match_results if matched_name)
unmatched_count = sum(1 for _, _, matched_name, _, _ in match_results if not matched_name)
# Update batch
batch.matched_cards = matched_count
batch.unmatched_cards = unmatched_count
batch.match_results = match_results
batch.status = "completed"
batch.updated_at = func.now()
await db.flush()
return {
"batch_id": batch.id,
"status": "completed",
"total_cards": len(card_names),
"matched_cards": matched_count,
"unmatched_cards": unmatched_count,
"match_results": match_results,
}
except Exception as e:
batch.status = "failed"
batch.error_message = str(e)
batch.updated_at = func.now()
await db.flush()
return {
"batch_id": batch.id,
"status": "failed",
"error": str(e),
}
@staticmethod
async def get_batch_status(db: AsyncSession, batch_id: int) -> Optional[CardImportBatch]:
"""Get the status of an import batch."""
stmt = select(CardImportBatch).where(CardImportBatch.id == batch_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def get_batch_results(db: AsyncSession, batch_id: int) -> Optional[Dict[str, Any]]:
"""Get the match results for an import batch."""
batch = await ImportBatchProcessor.get_batch_status(db, batch_id)
if not batch:
return None
return batch.match_results
@staticmethod
async def confirm_batch(db: AsyncSession, batch_id: int, user_id: int) -> UserCardImportRecord:
"""
Confirm an import batch.
Args:
db: Database session
batch_id: ID of the batch to confirm
user_id: ID of the user confirming
Returns:
UserCardImportRecord for the confirmed import
"""
batch = await ImportBatchProcessor.get_batch_status(db, batch_id)
if not batch:
raise ValueError(f"Import batch {batch_id} not found")
if batch.status != "completed":
raise ValueError(f"Import batch {batch_id} is not completed (status: {batch.status})")
# Check if already confirmed
stmt = select(UserCardImportRecord).where(
UserCardImportRecord.user_id == user_id,
UserCardImportRecord.batch_id == batch_id,
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
return existing
# Create confirmation record
record = UserCardImportRecord(
user_id=user_id,
batch_id=batch_id,
is_confirmed=True,
)
db.add(record)
await db.flush()
return record
@staticmethod
async def get_user_imports(db: AsyncSession, user_id: int) -> List[CardImportBatch]:
"""Get all import batches for a user."""
stmt = select(CardImportBatch).where(CardImportBatch.user_id == user_id).order_by(CardImportBatch.created_at.desc())
result = await db.execute(stmt)
return result.scalars().all()
@staticmethod
async def delete_batch(db: AsyncSession, batch_id: int, user_id: int) -> bool:
"""
Delete an import batch.
Args:
db: Database session
batch_id: ID of the batch to delete
user_id: ID of the user deleting
Returns:
True if deleted successfully, False if not found
"""
batch = await ImportBatchProcessor.get_batch_status(db, batch_id)
if not batch or batch.user_id != user_id:
return False
# Delete confirmation records
stmt = delete(UserCardImportRecord).where(UserCardImportRecord.batch_id == batch_id)
await db.execute(stmt)
# Delete batch
stmt = delete(CardImportBatch).where(CardImportBatch.id == batch_id)
await db.execute(stmt)
await db.flush()
return True
+7
View File
@@ -34,3 +34,10 @@ aiosqlite==0.20.0
# Linting
ruff==0.6.5
# Card import and fuzzy matching
python-Levenshtein==0.25.1
thefuzz==0.22.1
openpyxl==3.1.2
pandas==2.2.2
odfpy==1.4.1