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