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:
+309
-229
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user