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.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
Database engine and session management.
|
||||
|
||||
Provides async SQLAlchemy engine and session factory for dependency injection.
|
||||
Supports dual database connections for cockatrice app and mtgjson data.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
@@ -9,6 +10,7 @@ from app.core.settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Primary database engine (cockatrice app)
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
@@ -23,17 +25,32 @@ async_session = async_sessionmaker(
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
# Secondary database engine (mtgjson data)
|
||||
mtg_engine = create_async_engine(
|
||||
settings.MTG_DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=5,
|
||||
)
|
||||
|
||||
mtg_async_session = async_sessionmaker(
|
||||
mtg_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all ORM models."""
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["Base", "get_db", "async_session", "engine"]
|
||||
__all__ = ["Base", "get_db", "async_session", "engine", "mtg_get_db", "mtg_async_session", "mtg_engine"]
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""FastAPI dependency that provides a database session."""
|
||||
"""FastAPI dependency that provides a database session for the cockatrice app."""
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
@@ -43,3 +60,16 @@ async def get_db() -> AsyncSession:
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def mtg_get_db() -> AsyncSession:
|
||||
"""FastAPI dependency that provides a database session for mtgjson data."""
|
||||
async with mtg_async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Redis client and caching layer."""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from app.core.settings import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Redis client instance
|
||||
redis_client: Optional[aioredis.Redis] = None
|
||||
|
||||
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
"""Get Redis client instance."""
|
||||
global redis_client
|
||||
if redis_client is None:
|
||||
try:
|
||||
redis_client = aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5,
|
||||
)
|
||||
await redis_client.ping()
|
||||
logger.info("Connected to Redis")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to connect to Redis: {e}")
|
||||
redis_client = None
|
||||
return redis_client
|
||||
|
||||
|
||||
async def close_redis():
|
||||
"""Close Redis connection."""
|
||||
global redis_client
|
||||
if redis_client:
|
||||
await redis_client.close()
|
||||
redis_client = None
|
||||
logger.info("Closed Redis connection")
|
||||
|
||||
|
||||
async def cache_get(key: str) -> Optional[str]:
|
||||
"""Get cached value."""
|
||||
try:
|
||||
client = await get_redis()
|
||||
if not client:
|
||||
return None
|
||||
value = await client.get(key)
|
||||
return value
|
||||
except Exception as e:
|
||||
logger.error(f"Cache get error for key {key}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def cache_set(key: str, value: str, ttl: int = 3600):
|
||||
"""Set cached value with TTL (default 1 hour)."""
|
||||
try:
|
||||
client = await get_redis()
|
||||
if not client:
|
||||
return
|
||||
await client.set(key, value, ex=ttl)
|
||||
except Exception as e:
|
||||
logger.error(f"Cache set error for key {key}: {e}")
|
||||
|
||||
|
||||
async def cache_delete(key: str):
|
||||
"""Delete cached value."""
|
||||
try:
|
||||
client = await get_redis()
|
||||
if not client:
|
||||
return
|
||||
await client.delete(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Cache delete error for key {key}: {e}")
|
||||
|
||||
|
||||
async def cache_invalidate_pattern(pattern: str):
|
||||
"""Invalidate all cached keys matching pattern."""
|
||||
try:
|
||||
client = await get_redis()
|
||||
if not client:
|
||||
return
|
||||
keys = await client.keys(pattern)
|
||||
if keys:
|
||||
await client.delete(*keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Cache invalidate error for pattern {pattern}: {e}")
|
||||
@@ -39,6 +39,9 @@ def create_access_token(
|
||||
minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
# Use JWT_SECRET_KEY if available, fall back to SECRET_KEY
|
||||
secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY
|
||||
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"exp": expire,
|
||||
@@ -46,7 +49,7 @@ def create_access_token(
|
||||
"type": "access",
|
||||
"privlevel": privlevel,
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
return jwt.encode(payload, secret, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str, privlevel: str = "User") -> str:
|
||||
@@ -54,6 +57,10 @@ def create_refresh_token(subject: str, privlevel: str = "User") -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
|
||||
# Use JWT_SECRET_KEY if available, fall back to SECRET_KEY
|
||||
secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY
|
||||
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"exp": expire,
|
||||
@@ -61,15 +68,18 @@ def create_refresh_token(subject: str, privlevel: str = "User") -> str:
|
||||
"type": "refresh",
|
||||
"privlevel": privlevel,
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
return jwt.encode(payload, secret, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and validate a JWT token."""
|
||||
try:
|
||||
# Use JWT_SECRET_KEY if available, fall back to SECRET_KEY
|
||||
secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY
|
||||
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.SECRET_KEY,
|
||||
secret,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
|
||||
@@ -13,12 +13,16 @@ class Settings(BaseSettings):
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "Cockatrice Web"
|
||||
APP_VERSION: str = "0.1.0"
|
||||
APP_VERSION: str = "0.2.0"
|
||||
DEBUG: bool = False
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
JWT_SECRET_KEY: str = "change-me-in-production"
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://cockatrice:cockatrice@localhost:5432/cockatrice"
|
||||
# Database - Primary (cockatrice app)
|
||||
DATABASE_URL: str = "postgresql+asyncpg://cockatrice:cockatrice_pass@localhost:5432/cockatrice"
|
||||
|
||||
# Database - Secondary (mtgjson data)
|
||||
MTG_DATABASE_URL: str = "postgresql+asyncpg://cockatrice:cockatrice_pass@localhost:5432/mtgdata"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
@@ -29,7 +33,7 @@ class Settings(BaseSettings):
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8080"]
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8000"]
|
||||
|
||||
# Email (for password reset, account activation)
|
||||
SMTP_HOST: Optional[str] = None
|
||||
@@ -43,9 +47,31 @@ class Settings(BaseSettings):
|
||||
MAX_LOGIN_ATTEMPTS: int = 5
|
||||
LOGIN_BLOCK_MINUTES: int = 15
|
||||
|
||||
# MTG Data Refresh
|
||||
MTG_REFRESH_INTERVAL_DAYS: int = 7
|
||||
DATA_DIR: str = "/app/data"
|
||||
UPLOAD_DIR: str = "/app/uploads"
|
||||
|
||||
# Database configuration
|
||||
DB_CONFIG: dict = {
|
||||
"engine": "postgresql+asyncpg",
|
||||
"user": "cockatrice",
|
||||
"password": "cockatrice_pass",
|
||||
"host": "postgres",
|
||||
"port": 5432,
|
||||
}
|
||||
|
||||
# Redis configuration
|
||||
REDIS_CONFIG: dict = {
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"db": 0,
|
||||
}
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
extra = "allow" # Allow extra env vars
|
||||
|
||||
|
||||
@lru_cache()
|
||||
|
||||
Reference in New Issue
Block a user