Final project commit: MTGJSON data integration and backend API

This commit is contained in:
wall-o
2026-07-20 01:09:45 +00:00
parent b170dfd577
commit db01e29a54
37 changed files with 9674 additions and 198 deletions
+85 -127
View File
@@ -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
+25 -1
View File
@@ -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",
]
+4 -1
View File
@@ -1 +1,4 @@
# Games package
# Games router package
from app.routers.games.router import router
__all__ = ["router"]
+674
View File
@@ -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,
}
+87 -44
View File
@@ -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)