Final project commit: MTGJSON data integration and backend API
This commit is contained in:
+85
-127
@@ -1,37 +1,79 @@
|
||||
"""
|
||||
FastAPI application factory and middleware setup.
|
||||
MTG Online Backend Application
|
||||
|
||||
Configures CORS, authentication, and error handling.
|
||||
FastAPI application for the MTG Online multiplayer platform.
|
||||
Mounts all routers and provides centralized configuration.
|
||||
|
||||
## Routers
|
||||
- Authentication: /auth/*
|
||||
- Users: /users/*
|
||||
- Decks: /decks/*
|
||||
- Rooms: /rooms/*
|
||||
- Games: /games/*
|
||||
- Admin: /admin/*
|
||||
- MTG Cards: /api/cards/*
|
||||
- Card Interactions: /interactions/*
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.settings import get_settings
|
||||
from app.routers import auth, users, decks, rooms, games, admin, card_router
|
||||
from app.core.database import engine, mtg_engine, async_session, mtg_async_session
|
||||
from app.routers import auth, users, decks, rooms, games, admin, card_router, interactions
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=getattr(logging, settings.LOG_LEVEL))
|
||||
logger = logging.getLogger(__name__)
|
||||
def setup_logging(debug: bool = False) -> None:
|
||||
"""Configure application logging with verbose support."""
|
||||
level = logging.DEBUG if debug else logging.INFO
|
||||
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
]
|
||||
)
|
||||
|
||||
if debug:
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
|
||||
logging.getLogger('sqlalchemy.pool').setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Logging initialized at level {logging.getLevelName(level)}")
|
||||
|
||||
|
||||
def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan events for startup and shutdown."""
|
||||
settings = get_settings()
|
||||
|
||||
# Startup
|
||||
setup_logging(debug=settings.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"MTG Online Backend starting (v{settings.APP_VERSION})")
|
||||
logger.info(f"Database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'configured'}")
|
||||
logger.info(f"MTG Database: {settings.MTG_DATABASE_URL.split('@')[1] if '@' in settings.MTG_DATABASE_URL else 'configured'}")
|
||||
logger.info(f"Redis: {settings.REDIS_URL}")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down MTG Online Backend")
|
||||
# Engine disposal is handled by FastAPI's shutdown events
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
title="MTG Online Backend API",
|
||||
description="Backend API for the MTG Online multiplayer platform",
|
||||
version="0.2.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS configuration
|
||||
# CORS middleware
|
||||
settings = get_settings()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
@@ -40,116 +82,32 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Track initialization status
|
||||
mtg_data_ready = False
|
||||
mtg_data_count = 0
|
||||
|
||||
# Mount all routers
|
||||
app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
||||
app.include_router(users.router, prefix="/users", tags=["Users"])
|
||||
app.include_router(decks.router, prefix="/decks", tags=["Decks"])
|
||||
app.include_router(rooms.router, prefix="/rooms", tags=["Rooms"])
|
||||
app.include_router(games.router, prefix="/games", tags=["Games"])
|
||||
app.include_router(admin.router, prefix="/admin", tags=["Admin"])
|
||||
app.include_router(card_router.router, prefix="/api", tags=["MTG Cards"])
|
||||
app.include_router(interactions.router, tags=["Card Interactions"])
|
||||
|
||||
|
||||
async def check_mtg_data_count() -> int:
|
||||
"""Check if MTG data has been loaded."""
|
||||
try:
|
||||
engine = create_async_engine(settings.MTG_DATABASE_URL)
|
||||
async with AsyncSession(engine) as session:
|
||||
stmt = text("SELECT COUNT(*) FROM mtg_cards")
|
||||
result = await session.execute(stmt)
|
||||
count = result.scalar()
|
||||
await engine.dispose()
|
||||
return count or 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking MTG data count: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def trigger_initial_mtg_download():
|
||||
"""Trigger initial MTGJSON download on first startup."""
|
||||
global mtg_data_ready, mtg_data_count
|
||||
|
||||
logger.info("Checking MTG data status...")
|
||||
count = await check_mtg_data_count()
|
||||
|
||||
if count == 0:
|
||||
logger.info("No MTG data found. Triggering initial download...")
|
||||
mtg_data_ready = False
|
||||
|
||||
try:
|
||||
# Import and run the refresh script
|
||||
from app.scripts.refresh_mtg import main as refresh_main
|
||||
await refresh_main()
|
||||
|
||||
# Re-check count after download
|
||||
mtg_data_count = await check_mtg_data_count()
|
||||
mtg_data_ready = mtg_data_count > 0
|
||||
|
||||
if mtg_data_ready:
|
||||
logger.info(f"MTG data loaded successfully: {mtg_data_count} cards")
|
||||
else:
|
||||
logger.warning("MTG data download completed but no cards found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load MTG data: {e}")
|
||||
mtg_data_ready = False
|
||||
else:
|
||||
logger.info(f"MTG data already loaded: {count} cards")
|
||||
mtg_data_ready = True
|
||||
mtg_data_count = count
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Run initial MTG data download on startup."""
|
||||
logger.info("Starting MTG Online backend...")
|
||||
|
||||
# Run MTG data download in background to not block startup
|
||||
asyncio.create_task(trigger_initial_mtg_download())
|
||||
|
||||
|
||||
# Global exception handlers
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
"""Handle unhandled exceptions gracefully."""
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"])
|
||||
app.include_router(card_router.router, prefix="/api/v1/mtg/cards", tags=["MTG Cards"])
|
||||
app.include_router(users.router, prefix="/api/v1/users", tags=["Users"])
|
||||
app.include_router(decks.router, prefix="/api/v1/decks", tags=["Decks"])
|
||||
app.include_router(rooms.router, prefix="/api/v1/rooms", tags=["Rooms"])
|
||||
app.include_router(games.router, prefix="/api/v1/games", tags=["Games"])
|
||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["Admin"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> Dict[str, Any]:
|
||||
"""Health check endpoint with MTG data status."""
|
||||
health_status = {
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": settings.APP_VERSION,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# Check database connectivity
|
||||
try:
|
||||
engine = create_async_engine(settings.DATABASE_URL)
|
||||
async with AsyncSession(engine) as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
await engine.dispose()
|
||||
health_status["database"] = "connected"
|
||||
except Exception as e:
|
||||
health_status["status"] = "degraded"
|
||||
health_status["database"] = f"error: {str(e)}"
|
||||
|
||||
# Check MTG data status
|
||||
health_status["mtg_data"] = {
|
||||
"ready": mtg_data_ready,
|
||||
"count": mtg_data_count,
|
||||
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
async def root():
|
||||
"""Root endpoint with API information."""
|
||||
return {
|
||||
"name": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
if not mtg_data_ready:
|
||||
health_status["status"] = "initializing"
|
||||
|
||||
return health_status
|
||||
|
||||
@@ -1 +1,25 @@
|
||||
# Routers package
|
||||
"""
|
||||
Router package exports.
|
||||
|
||||
All routers are mounted in app/main.py.
|
||||
This package provides centralized access to all router modules.
|
||||
"""
|
||||
from app.routers import auth
|
||||
from app.routers import users
|
||||
from app.routers import decks
|
||||
from app.routers import rooms
|
||||
from app.routers import games
|
||||
from app.routers import admin
|
||||
from app.routers import card_router
|
||||
from app.routers import interactions
|
||||
|
||||
__all__ = [
|
||||
"auth",
|
||||
"users",
|
||||
"decks",
|
||||
"rooms",
|
||||
"games",
|
||||
"admin",
|
||||
"card_router",
|
||||
"interactions",
|
||||
]
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
# Games package
|
||||
# Games router package
|
||||
from app.routers.games.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
"""
|
||||
Card interaction router for MTG card interaction database.
|
||||
|
||||
Provides endpoints for searching card synergies, counters, evolutions,
|
||||
and getting recommendations based on card interactions.
|
||||
"""
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.database import mtg_get_db
|
||||
from app.core.redis_client import cache_get, cache_set
|
||||
|
||||
router = APIRouter(prefix="/interactions", tags=["Card Interactions"])
|
||||
|
||||
|
||||
@router.get("/synergies/{card_id}")
|
||||
async def get_card_synergies(
|
||||
card_id: int,
|
||||
synergy_type: Optional[str] = Query(None, description="Filter by synergy type (archetype, mechanic, mana, combo)"),
|
||||
min_strength: int = Query(1, ge=1, le=5, description="Minimum synergy strength"),
|
||||
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),
|
||||
):
|
||||
"""
|
||||
Get synergies for a specific card.
|
||||
|
||||
Synergies are positive interactions where cards work well together.
|
||||
"""
|
||||
cache_key = f"synergies:{card_id}:{synergy_type}:{min_strength}:{limit}:{offset}"
|
||||
|
||||
try:
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
# Build query
|
||||
conditions = ["card_a_id = :card_id OR card_b_id = :card_id"]
|
||||
params = {"card_id": card_id}
|
||||
|
||||
if synergy_type:
|
||||
conditions.append("synergy_type = :synergy_type")
|
||||
params["synergy_type"] = synergy_type
|
||||
|
||||
if min_strength:
|
||||
conditions.append("strength >= :min_strength")
|
||||
params["min_strength"] = min_strength
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
query = f"""
|
||||
SELECT id, card_a_id, card_b_id, synergy_type, strength, notes, confidence
|
||||
FROM mtg_card_synergies
|
||||
WHERE {where_clause}
|
||||
ORDER BY strength DESC, confidence DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
# Execute query
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
synergies = []
|
||||
for row in rows:
|
||||
synergies.append({
|
||||
"id": row[0],
|
||||
"card_a_id": row[1],
|
||||
"card_b_id": row[2],
|
||||
"synergy_type": row[3],
|
||||
"strength": row[4],
|
||||
"notes": row[5],
|
||||
"confidence": row[6],
|
||||
})
|
||||
|
||||
# Get count for pagination
|
||||
count_query = f"""
|
||||
SELECT COUNT(*)
|
||||
FROM mtg_card_synergies
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
count_result = await db.execute(text(count_query), params)
|
||||
total = count_result.scalar()
|
||||
|
||||
# Cache for 10 minutes
|
||||
await cache_set(cache_key, {"synergies": synergies, "total": total}, ttl=600)
|
||||
|
||||
return {
|
||||
"cached": False,
|
||||
"results": synergies,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error fetching synergies: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/counters/{card_id}")
|
||||
async def get_card_counters(
|
||||
card_id: int,
|
||||
counter_type: Optional[str] = Query(None, description="Filter by counter type (color, stats, spell, keyword)"),
|
||||
min_strength: int = Query(1, ge=1, le=5, description="Minimum counter strength"),
|
||||
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),
|
||||
):
|
||||
"""
|
||||
Get counters for a specific card.
|
||||
|
||||
Counters are negative interactions where one card is disadvantaged by another.
|
||||
"""
|
||||
cache_key = f"counters:{card_id}:{counter_type}:{min_strength}:{limit}:{offset}"
|
||||
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
conditions = ["card_a_id = :card_id OR card_b_id = :card_id"]
|
||||
params = {"card_id": card_id}
|
||||
|
||||
if counter_type:
|
||||
conditions.append("counter_type = :counter_type")
|
||||
params["counter_type"] = counter_type
|
||||
|
||||
if min_strength:
|
||||
conditions.append("strength >= :min_strength")
|
||||
params["min_strength"] = min_strength
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
query = f"""
|
||||
SELECT id, card_a_id, card_b_id, counter_type, strength, notes, confidence
|
||||
FROM mtg_card_counters
|
||||
WHERE {where_clause}
|
||||
ORDER BY strength DESC, confidence DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
counters = []
|
||||
for row in rows:
|
||||
counters.append({
|
||||
"id": row[0],
|
||||
"card_a_id": row[1],
|
||||
"card_b_id": row[2],
|
||||
"counter_type": row[3],
|
||||
"strength": row[4],
|
||||
"notes": row[5],
|
||||
"confidence": row[6],
|
||||
})
|
||||
|
||||
count_query = f"""
|
||||
SELECT COUNT(*)
|
||||
FROM mtg_card_counters
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
count_result = await db.execute(text(count_query), params)
|
||||
total = count_result.scalar()
|
||||
|
||||
await cache_set(cache_key, {"counters": counters, "total": total}, ttl=600)
|
||||
|
||||
return {
|
||||
"cached": False,
|
||||
"results": counters,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/evolutions/{card_id}")
|
||||
async def get_card_evolutions(
|
||||
card_id: int,
|
||||
evolution_type: Optional[str] = Query(None, description="Filter by evolution type (reprint, transform, double_sided)"),
|
||||
min_strength: int = Query(1, ge=1, le=5, description="Minimum strength"),
|
||||
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),
|
||||
):
|
||||
"""
|
||||
Get evolutions for a specific card.
|
||||
|
||||
Evolutions track when a card has been reprinted, transformed, or evolved.
|
||||
"""
|
||||
cache_key = f"evolutions:{card_id}:{evolution_type}:{min_strength}:{limit}:{offset}"
|
||||
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
conditions = ["card_id = :card_id"]
|
||||
params = {"card_id": card_id}
|
||||
|
||||
if evolution_type:
|
||||
conditions.append("evolution_type = :evolution_type")
|
||||
params["evolution_type"] = evolution_type
|
||||
|
||||
if min_strength:
|
||||
conditions.append("strength >= :min_strength")
|
||||
params["min_strength"] = min_strength
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
query = f"""
|
||||
SELECT id, card_id, evolved_card_id, evolution_type, strength, notes, confidence
|
||||
FROM mtg_card_evolution
|
||||
WHERE {where_clause}
|
||||
ORDER BY strength DESC, confidence DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
evolutions = []
|
||||
for row in rows:
|
||||
evolutions.append({
|
||||
"id": row[0],
|
||||
"card_id": row[1],
|
||||
"evolved_card_id": row[2],
|
||||
"evolution_type": row[3],
|
||||
"strength": row[4],
|
||||
"notes": row[5],
|
||||
"confidence": row[6],
|
||||
})
|
||||
|
||||
count_query = f"""
|
||||
SELECT COUNT(*)
|
||||
FROM mtg_card_evolution
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
count_result = await db.execute(text(count_query), params)
|
||||
total = count_result.scalar()
|
||||
|
||||
await cache_set(cache_key, {"evolutions": evolutions, "total": total}, ttl=600)
|
||||
|
||||
return {
|
||||
"cached": False,
|
||||
"results": evolutions,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recommend/{card_id}")
|
||||
async def get_card_recommendations(
|
||||
card_id: int,
|
||||
recommendation_type: str = Query("synergy", description="Type of recommendation (synergy, counter, evolution)"),
|
||||
limit: int = Query(10, ge=1, le=100, description="Maximum results"),
|
||||
db: AsyncSession = Depends(mtg_get_db),
|
||||
):
|
||||
"""
|
||||
Get interaction recommendations for a card.
|
||||
|
||||
Provides cards that work well together or counter a specific card.
|
||||
"""
|
||||
cache_key = f"recommend:{card_id}:{recommendation_type}:{limit}"
|
||||
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
if recommendation_type == "synergy":
|
||||
# Get cards that synergize with this card
|
||||
query = """
|
||||
SELECT
|
||||
CASE WHEN card_a_id = :card_id THEN card_b_id ELSE card_a_id END as recommended_card_id,
|
||||
strength,
|
||||
synergy_type,
|
||||
confidence
|
||||
FROM mtg_card_synergies
|
||||
WHERE card_a_id = :card_id OR card_b_id = :card_id
|
||||
ORDER BY strength DESC, confidence DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
elif recommendation_type == "counter":
|
||||
# Get cards that counter this card
|
||||
query = """
|
||||
SELECT
|
||||
CASE WHEN card_a_id = :card_id THEN card_b_id ELSE card_a_id END as recommended_card_id,
|
||||
strength,
|
||||
counter_type,
|
||||
confidence
|
||||
FROM mtg_card_counters
|
||||
WHERE card_a_id = :card_id OR card_b_id = :card_id
|
||||
ORDER BY strength DESC, confidence DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
elif recommendation_type == "evolution":
|
||||
# Get evolutions of this card
|
||||
query = """
|
||||
SELECT evolved_card_id as recommended_card_id,
|
||||
strength,
|
||||
evolution_type,
|
||||
confidence
|
||||
FROM mtg_card_evolution
|
||||
WHERE card_id = :card_id
|
||||
ORDER BY strength DESC, confidence DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid recommendation type: {recommendation_type}")
|
||||
|
||||
params = {"card_id": card_id, "limit": limit}
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
recommendations = []
|
||||
for row in rows:
|
||||
recommendations.append({
|
||||
"recommended_card_id": row[0],
|
||||
"strength": row[1],
|
||||
"type": recommendation_type,
|
||||
"subtype": row[2],
|
||||
"confidence": row[3],
|
||||
})
|
||||
|
||||
await cache_set(cache_key, recommendations, ttl=900)
|
||||
|
||||
return {
|
||||
"cached": False,
|
||||
"results": recommendations,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/search/synergies")
|
||||
async def search_synergies(
|
||||
card_a_id: Optional[int] = Query(None, description="Card A ID"),
|
||||
card_b_id: Optional[int] = Query(None, description="Card B ID"),
|
||||
synergy_type: Optional[str] = Query(None, description="Filter by synergy type"),
|
||||
min_strength: int = Query(1, ge=1, le=5, description="Minimum strength"),
|
||||
min_confidence: float = Query(0.0, ge=0.0, le=1.0, description="Minimum confidence"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
|
||||
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
||||
db: AsyncSession = Depends(mtg_get_db),
|
||||
):
|
||||
"""
|
||||
Search synergies with multiple filters.
|
||||
"""
|
||||
cache_key = f"search_synergies:{card_a_id}:{card_b_id}:{synergy_type}:{min_strength}:{min_confidence}:{limit}:{offset}"
|
||||
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
conditions = []
|
||||
params = {}
|
||||
|
||||
if card_a_id:
|
||||
conditions.append("card_a_id = :card_a_id")
|
||||
params["card_a_id"] = card_a_id
|
||||
|
||||
if card_b_id:
|
||||
conditions.append("card_b_id = :card_b_id")
|
||||
params["card_b_id"] = card_b_id
|
||||
|
||||
if synergy_type:
|
||||
conditions.append("synergy_type = :synergy_type")
|
||||
params["synergy_type"] = synergy_type
|
||||
|
||||
if min_strength:
|
||||
conditions.append("strength >= :min_strength")
|
||||
params["min_strength"] = min_strength
|
||||
|
||||
if min_confidence:
|
||||
conditions.append("confidence >= :min_confidence")
|
||||
params["min_confidence"] = min_confidence
|
||||
|
||||
where_clause = " AND ".join(conditions) if conditions else "TRUE"
|
||||
|
||||
query = f"""
|
||||
SELECT id, card_a_id, card_b_id, synergy_type, strength, notes, confidence
|
||||
FROM mtg_card_synergies
|
||||
WHERE {where_clause}
|
||||
ORDER BY strength DESC, confidence DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
synergies = []
|
||||
for row in rows:
|
||||
synergies.append({
|
||||
"id": row[0],
|
||||
"card_a_id": row[1],
|
||||
"card_b_id": row[2],
|
||||
"synergy_type": row[3],
|
||||
"strength": row[4],
|
||||
"notes": row[5],
|
||||
"confidence": row[6],
|
||||
})
|
||||
|
||||
count_query = f"""
|
||||
SELECT COUNT(*)
|
||||
FROM mtg_card_synergies
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
count_result = await db.execute(text(count_query), params)
|
||||
total = count_result.scalar()
|
||||
|
||||
await cache_set(cache_key, {"synergies": synergies, "total": total}, ttl=600)
|
||||
|
||||
return {
|
||||
"cached": False,
|
||||
"results": synergies,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/search/counters")
|
||||
async def search_counters(
|
||||
card_a_id: Optional[int] = Query(None, description="Card A ID"),
|
||||
card_b_id: Optional[int] = Query(None, description="Card B ID"),
|
||||
counter_type: Optional[str] = Query(None, description="Filter by counter type"),
|
||||
min_strength: int = Query(1, ge=1, le=5, description="Minimum strength"),
|
||||
min_confidence: float = Query(0.0, ge=0.0, le=1.0, description="Minimum confidence"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
|
||||
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
||||
db: AsyncSession = Depends(mtg_get_db),
|
||||
):
|
||||
"""
|
||||
Search counters with multiple filters.
|
||||
"""
|
||||
cache_key = f"search_counters:{card_a_id}:{card_b_id}:{counter_type}:{min_strength}:{min_confidence}:{limit}:{offset}"
|
||||
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
conditions = []
|
||||
params = {}
|
||||
|
||||
if card_a_id:
|
||||
conditions.append("card_a_id = :card_a_id")
|
||||
params["card_a_id"] = card_a_id
|
||||
|
||||
if card_b_id:
|
||||
conditions.append("card_b_id = :card_b_id")
|
||||
params["card_b_id"] = card_b_id
|
||||
|
||||
if counter_type:
|
||||
conditions.append("counter_type = :counter_type")
|
||||
params["counter_type"] = counter_type
|
||||
|
||||
if min_strength:
|
||||
conditions.append("strength >= :min_strength")
|
||||
params["min_strength"] = min_strength
|
||||
|
||||
if min_confidence:
|
||||
conditions.append("confidence >= :min_confidence")
|
||||
params["min_confidence"] = min_confidence
|
||||
|
||||
where_clause = " AND ".join(conditions) if conditions else "TRUE"
|
||||
|
||||
query = f"""
|
||||
SELECT id, card_a_id, card_b_id, counter_type, strength, notes, confidence
|
||||
FROM mtg_card_counters
|
||||
WHERE {where_clause}
|
||||
ORDER BY strength DESC, confidence DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
counters = []
|
||||
for row in rows:
|
||||
counters.append({
|
||||
"id": row[0],
|
||||
"card_a_id": row[1],
|
||||
"card_b_id": row[2],
|
||||
"counter_type": row[3],
|
||||
"strength": row[4],
|
||||
"notes": row[5],
|
||||
"confidence": row[6],
|
||||
})
|
||||
|
||||
count_query = f"""
|
||||
SELECT COUNT(*)
|
||||
FROM mtg_card_counters
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
count_result = await db.execute(text(count_query), params)
|
||||
total = count_result.scalar()
|
||||
|
||||
await cache_set(cache_key, {"counters": counters, "total": total}, ttl=600)
|
||||
|
||||
return {
|
||||
"cached": False,
|
||||
"results": counters,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/search/evolutions")
|
||||
async def search_evolutions(
|
||||
card_id: Optional[int] = Query(None, description="Card ID"),
|
||||
evolved_card_id: Optional[int] = Query(None, description="Evolved Card ID"),
|
||||
evolution_type: Optional[str] = Query(None, description="Filter by evolution type"),
|
||||
min_strength: int = Query(1, ge=1, le=5, description="Minimum strength"),
|
||||
min_confidence: float = Query(0.0, ge=0.0, le=1.0, description="Minimum confidence"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum results"),
|
||||
offset: int = Query(0, ge=0, description="Number of results to skip"),
|
||||
db: AsyncSession = Depends(mtg_get_db),
|
||||
):
|
||||
"""
|
||||
Search evolutions with multiple filters.
|
||||
"""
|
||||
cache_key = f"search_evolutions:{card_id}:{evolved_card_id}:{evolution_type}:{min_strength}:{min_confidence}:{limit}:{offset}"
|
||||
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
conditions = []
|
||||
params = {}
|
||||
|
||||
if card_id:
|
||||
conditions.append("card_id = :card_id")
|
||||
params["card_id"] = card_id
|
||||
|
||||
if evolved_card_id:
|
||||
conditions.append("evolved_card_id = :evolved_card_id")
|
||||
params["evolved_card_id"] = evolved_card_id
|
||||
|
||||
if evolution_type:
|
||||
conditions.append("evolution_type = :evolution_type")
|
||||
params["evolution_type"] = evolution_type
|
||||
|
||||
if min_strength:
|
||||
conditions.append("strength >= :min_strength")
|
||||
params["min_strength"] = min_strength
|
||||
|
||||
if min_confidence:
|
||||
conditions.append("confidence >= :min_confidence")
|
||||
params["min_confidence"] = min_confidence
|
||||
|
||||
where_clause = " AND ".join(conditions) if conditions else "TRUE"
|
||||
|
||||
query = f"""
|
||||
SELECT id, card_id, evolved_card_id, evolution_type, strength, notes, confidence
|
||||
FROM mtg_card_evolution
|
||||
WHERE {where_clause}
|
||||
ORDER BY strength DESC, confidence DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
evolutions = []
|
||||
for row in rows:
|
||||
evolutions.append({
|
||||
"id": row[0],
|
||||
"card_id": row[1],
|
||||
"evolved_card_id": row[2],
|
||||
"evolution_type": row[3],
|
||||
"strength": row[4],
|
||||
"notes": row[5],
|
||||
"confidence": row[6],
|
||||
})
|
||||
|
||||
count_query = f"""
|
||||
SELECT COUNT(*)
|
||||
FROM mtg_card_evolution
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
count_result = await db.execute(text(count_query), params)
|
||||
total = count_result.scalar()
|
||||
|
||||
await cache_set(cache_key, {"evolutions": evolutions, "total": total}, ttl=600)
|
||||
|
||||
return {
|
||||
"cached": False,
|
||||
"results": evolutions,
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/{card_id}")
|
||||
async def get_card_interaction_stats(
|
||||
card_id: int,
|
||||
db: AsyncSession = Depends(mtg_get_db),
|
||||
):
|
||||
"""
|
||||
Get aggregated interaction statistics for a card.
|
||||
"""
|
||||
cache_key = f"interaction_stats:{card_id}"
|
||||
|
||||
cached = await cache_get(cache_key)
|
||||
if cached:
|
||||
return {"cached": True, "results": cached}
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
card_id,
|
||||
total_synergies,
|
||||
total_counters,
|
||||
total_evolutions,
|
||||
total_synergy_strength,
|
||||
avg_synergy_strength
|
||||
FROM mtg_card_interaction_stats
|
||||
WHERE card_id = :card_id
|
||||
"""
|
||||
params = {"card_id": card_id}
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
row = result.fetchone()
|
||||
|
||||
if not row:
|
||||
return {
|
||||
"cached": False,
|
||||
"results": {
|
||||
"card_id": card_id,
|
||||
"total_synergies": 0,
|
||||
"total_counters": 0,
|
||||
"total_evolutions": 0,
|
||||
"total_synergy_strength": 0,
|
||||
"avg_synergy_strength": 0,
|
||||
}
|
||||
}
|
||||
|
||||
stats = {
|
||||
"card_id": row[0],
|
||||
"total_synergies": row[1],
|
||||
"total_counters": row[2],
|
||||
"total_evolutions": row[3],
|
||||
"total_synergy_strength": row[4],
|
||||
"avg_synergy_strength": row[5],
|
||||
}
|
||||
|
||||
await cache_set(cache_key, stats, ttl=1800)
|
||||
|
||||
return {
|
||||
"cached": False,
|
||||
"results": stats,
|
||||
}
|
||||
@@ -44,30 +44,72 @@ async def download_mtgjson(session: aiohttp.ClientSession, output_path: Path) ->
|
||||
return False
|
||||
|
||||
|
||||
async def parse_mtgjson(filepath: Path) -> dict:
|
||||
"""Parse the AllPrintings JSON file."""
|
||||
async def parse_mtgjson(filepath: Path) -> tuple[dict, dict]:
|
||||
"""Parse the AllPrintings JSON file.
|
||||
|
||||
MTGJSON v5 AllPrintings structure:
|
||||
{
|
||||
"meta": {...},
|
||||
"data": {
|
||||
"10E": {"baseSetSize": 383, "block": "Core Set", "cards": [...]},
|
||||
"UNH": {...}
|
||||
}
|
||||
}
|
||||
|
||||
Returns:
|
||||
(sets_dict, cards_dict) where sets_dict maps set_code -> set_data
|
||||
and cards_dict maps set_code -> list of card dicts
|
||||
"""
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Verify structure
|
||||
if 'data' not in data or 'sets' not in data:
|
||||
raise ValueError("Invalid MTGJSON structure")
|
||||
# Verify structure - data key is required
|
||||
if 'data' not in data:
|
||||
raise ValueError("Invalid MTGJSON structure: missing 'data' key")
|
||||
|
||||
mtg_data = data['data']
|
||||
|
||||
# MTGJSON v5: data contains set codes directly as keys
|
||||
# Each set code maps to {baseSetSize, block, cards: [...]}
|
||||
sets_dict = {}
|
||||
cards_dict = {}
|
||||
|
||||
for set_code, set_data in mtg_data.items():
|
||||
# Skip if it looks like metadata, not a set
|
||||
if isinstance(set_data, dict) and 'baseSetSize' in set_data:
|
||||
# Convert release_date string to datetime object
|
||||
release_date_str = set_data.get('releaseDate')
|
||||
if release_date_str:
|
||||
try:
|
||||
set_data['releaseDate'] = datetime.fromisoformat(release_date_str.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
pass # Keep as string if parsing fails
|
||||
|
||||
sets_dict[set_code] = set_data
|
||||
# Extract cards for this set
|
||||
if 'cards' in set_data and isinstance(set_data['cards'], list):
|
||||
cards_dict[set_code] = set_data['cards']
|
||||
|
||||
if not sets_dict:
|
||||
raise ValueError("No sets found in MTGJSON data")
|
||||
|
||||
logger.info(f"Parsed {len(sets_dict)} sets, {sum(len(c) for c in cards_dict.values())} cards")
|
||||
return sets_dict, cards_dict
|
||||
|
||||
return data['data']
|
||||
except Exception as e:
|
||||
logger.error(f"Parse error: {e}")
|
||||
return {}
|
||||
return {}, {}
|
||||
|
||||
|
||||
async def update_database(session: AsyncSession, data: dict) -> tuple[int, int]:
|
||||
async def update_database(session: AsyncSession, sets_dict: dict, cards_dict: dict) -> tuple[int, int]:
|
||||
"""Update the database with parsed MTGJSON data."""
|
||||
cards_updated = 0
|
||||
sets_updated = 0
|
||||
|
||||
try:
|
||||
# Process sets
|
||||
for set_code, set_data in data.get('sets', {}).items():
|
||||
for set_code, set_data in sets_dict.items():
|
||||
stmt = text("""
|
||||
INSERT INTO mtg_sets (code, name, type, release_date, base_set_size,
|
||||
total_size, is_foil_only, is_non_foil_only,
|
||||
@@ -96,37 +138,38 @@ async def update_database(session: AsyncSession, data: dict) -> tuple[int, int]:
|
||||
})
|
||||
sets_updated += 1
|
||||
|
||||
# Process cards
|
||||
for card_data in data.get('cards', []):
|
||||
stmt = text("""
|
||||
INSERT INTO mtg_cards (set_id, name, mana_cost, type_line, oracle_text,
|
||||
power, toughness, rarity, layout, artist,
|
||||
flavor_text, numbers, identifiers, images)
|
||||
SELECT s.id, :name, :mana_cost, :type_line, :oracle_text,
|
||||
:power, :toughness, :rarity, :layout, :artist,
|
||||
:flavor_text, :numbers, :identifiers, :images
|
||||
FROM mtg_sets s
|
||||
WHERE s.code = :set_code
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
await session.execute(stmt, {
|
||||
'set_code': card_data.get('set'),
|
||||
'name': card_data.get('name'),
|
||||
'mana_cost': card_data.get('manaCost'),
|
||||
'type_line': card_data.get('type'),
|
||||
'oracle_text': card_data.get('text'),
|
||||
'power': card_data.get('power'),
|
||||
'toughness': card_data.get('toughness'),
|
||||
'rarity': card_data.get('rarity'),
|
||||
'layout': card_data.get('layout'),
|
||||
'artist': card_data.get('artist'),
|
||||
'flavor_text': card_data.get('flavorText'),
|
||||
'numbers': str(card_data.get('numbers', '')),
|
||||
'identifiers': json.dumps(card_data.get('identifiers', {})),
|
||||
'images': json.dumps(card_data.get('images', {})),
|
||||
})
|
||||
cards_updated += 1
|
||||
# Process cards grouped by set
|
||||
for set_code, cards in cards_dict.items():
|
||||
for card_data in cards:
|
||||
stmt = text("""
|
||||
INSERT INTO mtg_cards (set_id, name, mana_cost, type_line, oracle_text,
|
||||
power, toughness, rarity, layout, artist,
|
||||
flavor_text, numbers, identifiers, images)
|
||||
SELECT s.id, :name, :mana_cost, :type_line, :oracle_text,
|
||||
:power, :toughness, :rarity, :layout, :artist,
|
||||
:flavor_text, :numbers, :identifiers, :images
|
||||
FROM mtg_sets s
|
||||
WHERE s.code = :set_code
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
await session.execute(stmt, {
|
||||
'set_code': set_code,
|
||||
'name': card_data.get('name'),
|
||||
'mana_cost': card_data.get('manaCost'),
|
||||
'type_line': card_data.get('type'),
|
||||
'oracle_text': card_data.get('text'),
|
||||
'power': card_data.get('power'),
|
||||
'toughness': card_data.get('toughness'),
|
||||
'rarity': card_data.get('rarity'),
|
||||
'layout': card_data.get('layout'),
|
||||
'artist': card_data.get('artist'),
|
||||
'flavor_text': card_data.get('flavorText'),
|
||||
'numbers': str(card_data.get('numbers', '')),
|
||||
'identifiers': json.dumps(card_data.get('identifiers', {})),
|
||||
'images': json.dumps(card_data.get('images', {})),
|
||||
})
|
||||
cards_updated += 1
|
||||
|
||||
await session.commit()
|
||||
return cards_updated, sets_updated
|
||||
@@ -199,15 +242,15 @@ async def main():
|
||||
await log_refresh(engine, "FAILED", 0, 0, 0, "Download failed")
|
||||
return
|
||||
|
||||
# Parse data
|
||||
data = await parse_mtgjson(download_path)
|
||||
if not data:
|
||||
# Parse data (returns sets_dict, cards_dict)
|
||||
sets_dict, cards_dict = await parse_mtgjson(download_path)
|
||||
if not sets_dict:
|
||||
await log_refresh(engine, "FAILED", 0, 0, 0, "Parse failed")
|
||||
return
|
||||
|
||||
# Update database
|
||||
async with AsyncSession(engine) as db_session:
|
||||
cards_updated, sets_updated = await update_database(db_session, data)
|
||||
cards_updated, sets_updated = await update_database(db_session, sets_dict, cards_dict)
|
||||
|
||||
# Log success
|
||||
duration = int(time.time() - start_time)
|
||||
|
||||
Reference in New Issue
Block a user