- 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
369 lines
12 KiB
Python
369 lines
12 KiB
Python
"""
|
|
Card import router endpoints.
|
|
|
|
Provides endpoints for importing card collections from files,
|
|
viewing import status, and confirming imports.
|
|
"""
|
|
import json
|
|
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
|
|
from sqlalchemy.orm import selectinload
|
|
from datetime import datetime
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import get_current_user
|
|
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,
|
|
CardImportStatusResponse,
|
|
CardMatchResult,
|
|
CardImportSummary,
|
|
MessageResponse,
|
|
)
|
|
from app.schemas.user_deck_schemas import DeckCardResponse, DeckCardListResponse
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@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),
|
|
):
|
|
"""
|
|
Upload a card import file (XLSX, CSV, JSON, ODS).
|
|
|
|
Parses the file, performs fuzzy matching, and creates an import batch.
|
|
"""
|
|
user_id = int(current_user["user_id"])
|
|
|
|
# Validate file type
|
|
if not file.filename:
|
|
raise HTTPException(
|
|
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,
|
|
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.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),
|
|
):
|
|
"""Get the match results for 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"
|
|
)
|
|
|
|
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 []
|
|
|
|
matched_cards = []
|
|
unmatched_cards = []
|
|
|
|
for original_name, card_id, matched_name, confidence, match_type in match_results:
|
|
if matched_name:
|
|
matched_cards.append(CardMatchResult(
|
|
card_id=card_id,
|
|
card_name=original_name,
|
|
matched_name=matched_name,
|
|
match_type=match_type,
|
|
confidence=confidence,
|
|
))
|
|
else:
|
|
unmatched_cards.append(original_name)
|
|
|
|
return CardImportSummary(
|
|
total_cards=len(match_results),
|
|
matched_cards=matched_cards,
|
|
unmatched_cards=unmatched_cards,
|
|
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
|