Files
mtgonline/backend/app/scripts/refresh_mtg.py
T
akadmin 915242b330 feat: MTG database integration with Redis caching
- Added MTG card ORM models (mtg_cards, mtg_sets tables)
- Created card_database service with search, get_by_name, get_by_set
- Added Redis client with caching layer (3600s TTL default)
- Created card router with caching on all endpoints:
  - Search cards (5min cache)
  - Get card by name (10min cache)
  - Get cards by set (15min cache)
  - Get card types/rarities (30min cache)
  - Get sets (1hr cache)
  - Get statistics (1hr cache)
- Updated settings.py:
  - Added JWT_SECRET_KEY field
  - Added DB_CONFIG and REDIS_CONFIG dictionaries
- Updated security.py to use JWT_SECRET_KEY with fallback
- Updated auth.py to use timezone-aware datetimes
- Updated refresh_mtg.py to use settings instead of os.environ
- Updated mtg_monitor.py to use settings for connections
- Added services package with __init__.py

All 20 tests passing.
2026-07-18 18:41:05 +00:00

227 lines
8.2 KiB
Python

"""
MTGJSON Database Refresh Script
Downloads and updates the MTGJSON All Printings dataset weekly.
"""
import asyncio
import json
import logging
import os
import time
from datetime import datetime
from pathlib import Path
import aiohttp
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
from app.core.settings import get_settings
logger = logging.getLogger(__name__)
MTGJSON_API_URL = "https://mtgjson.com/api/v5/AllPrintings.json"
DATA_DIR = Path("/app/data")
REFRESH_INTERVAL_DAYS = 7
async def download_mtgjson(session: aiohttp.ClientSession, output_path: Path) -> bool:
"""Download the latest AllPrintings dataset."""
try:
logger.info(f"Downloading MTGJSON from {MTGJSON_API_URL}...")
async with session.get(MTGJSON_API_URL) as response:
if response.status != 200:
logger.error(f"Failed to download: {response.status}")
return False
with open(output_path, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
f.write(chunk)
logger.info(f"Downloaded to {output_path}")
return True
except Exception as e:
logger.error(f"Download error: {e}")
return False
async def parse_mtgjson(filepath: Path) -> dict:
"""Parse the AllPrintings JSON file."""
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")
return data['data']
except Exception as e:
logger.error(f"Parse error: {e}")
return {}
async def update_database(session: AsyncSession, data: 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():
stmt = text("""
INSERT INTO mtg_sets (code, name, type, release_date, base_set_size,
total_size, is_foil_only, is_non_foil_only,
digital, icon_svg_url, parent_code, mtgo_code)
VALUES (:code, :name, :type, :release_date, :base_set_size,
:total_size, :is_foil_only, :is_non_foil_only,
:digital, :icon_svg_url, :parent_code, :mtgo_code)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
updated_at = CURRENT_TIMESTAMP
""")
await session.execute(stmt, {
'code': set_code,
'name': set_data.get('name'),
'type': set_data.get('type'),
'release_date': set_data.get('releaseDate'),
'base_set_size': set_data.get('baseSetSize'),
'total_size': set_data.get('totalSize'),
'is_foil_only': set_data.get('isFoilOnly'),
'is_non_foil_only': set_data.get('isNonFoilOnly'),
'digital': set_data.get('digital'),
'icon_svg_url': set_data.get('iconSvgUri'),
'parent_code': set_data.get('parentCode'),
'mtgo_code': set_data.get('mtgoCode'),
})
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
await session.commit()
return cards_updated, sets_updated
except Exception as e:
logger.error(f"Database update error: {e}")
await session.rollback()
raise
async def check_last_refresh(engine: create_async_engine) -> datetime:
"""Check when the last refresh occurred."""
async with AsyncSession(engine) as session:
stmt = text("SELECT refresh_date FROM mtg_refresh_log ORDER BY refresh_date DESC LIMIT 1")
result = await session.execute(stmt)
row = result.fetchone()
if row:
return row[0]
return datetime.min
async def log_refresh(engine: create_async_engine, status: str, cards: int, sets: int,
duration: int, error: str = None):
"""Log the refresh operation."""
async with AsyncSession(engine) as session:
stmt = text("""
INSERT INTO mtg_refresh_log (status, cards_updated, sets_updated,
error_message, duration_seconds)
VALUES (:status, :cards, :sets, :error, :duration)
""")
await session.execute(stmt, {
'status': status,
'cards': cards,
'sets': sets,
'error': error,
'duration': duration,
})
await session.commit()
async def main():
"""Main refresh logic."""
logging.basicConfig(level=logging.INFO)
settings = get_settings()
DATA_DIR = Path(settings.DATA_DIR)
REFRESH_INTERVAL_DAYS = settings.MTG_REFRESH_INTERVAL_DAYS
# Use database URL from settings
engine = create_async_engine(settings.MTG_DATABASE_URL)
last_refresh = await check_last_refresh(engine)
refresh_needed = (datetime.now() - last_refresh).days >= REFRESH_INTERVAL_DAYS
if not refresh_needed:
logger.info("Refresh not needed. Last refresh was within interval.")
return
start_time = time.time()
try:
async with aiohttp.ClientSession() as session:
# Download dataset
download_path = DATA_DIR / "AllPrintings.json"
success = await download_mtgjson(session, download_path)
if not success:
await log_refresh(engine, "FAILED", 0, 0, 0, "Download failed")
return
# Parse data
data = await parse_mtgjson(download_path)
if not data:
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)
# Log success
duration = int(time.time() - start_time)
await log_refresh(engine, "SUCCESS", cards_updated, sets_updated, duration)
logger.info(f"Refresh completed: {cards_updated} cards, {sets_updated} sets in {duration}s")
except Exception as e:
duration = int(time.time() - start_time)
await log_refresh(engine, "FAILED", 0, 0, duration, str(e))
logger.error(f"Refresh failed: {e}")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())