From 2a95316f049fb40d3eff73cbcdb764c70a87187f Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 27 Jan 2026 15:37:19 -0600 Subject: [PATCH] Add FastAPI lifespan hooks and fix Phase 1 gaps - Add lifespan context manager to app/main.py with startup/shutdown hooks - Wire startup: init_db(), init_redis(), CardService.load_all() - Wire shutdown: close_db(), close_redis() - Add /health/ready endpoint for readiness checks - Add CORS middleware with configurable origins - Disable docs in production (only available in dev) - Export get_session_dependency from app/db/__init__.py for FastAPI DI - Add game_cache_ttl_seconds to Settings (configurable, was hardcoded) - Fix datetime.utcnow() deprecation (4 occurrences) -> datetime.now(UTC) - Update test to match S3 image URL (was placeholder CDN) All 974 tests passing. --- backend/app/config.py | 6 + backend/app/db/__init__.py | 18 ++- backend/app/db/models/collection.py | 4 +- backend/app/db/models/game.py | 8 +- backend/app/main.py | 159 +++++++++++++++++++- backend/app/services/card_service.py | 2 +- backend/app/services/game_state_manager.py | 6 +- backend/scripts/convert_cards.py | 2 +- backend/tests/scripts/test_convert_cards.py | 2 +- 9 files changed, 190 insertions(+), 17 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index bec16f0..6e82687 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -161,6 +161,12 @@ class Settings(BaseSettings): le=600, description="Turn timeout in seconds (2 minutes default)", ) + game_cache_ttl_seconds: int = Field( + default=86400, # 24 hours + ge=3600, + le=604800, # 1 week max + description="Game state cache TTL in Redis (seconds)", + ) # Card Data card_data_path: str = Field( diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py index a4cf4c2..26f51ed 100644 --- a/backend/app/db/__init__.py +++ b/backend/app/db/__init__.py @@ -13,8 +13,17 @@ Usage: async with get_session() as session: user = await session.get(User, user_id) + # For FastAPI dependency injection: + from app.db import get_session_dependency + from fastapi import Depends + + @app.get("/users/{user_id}") + async def get_user(session: AsyncSession = Depends(get_session_dependency)): + ... + Exports: - get_session: Async context manager for database sessions + - get_session_dependency: FastAPI dependency for sessions - get_engine: Get the async engine instance - Base: Declarative base class for models - init_db: Initialize database (create tables) @@ -22,11 +31,18 @@ Exports: """ from app.db.base import Base -from app.db.session import close_db, get_engine, get_session, init_db +from app.db.session import ( + close_db, + get_engine, + get_session, + get_session_dependency, + init_db, +) __all__ = [ "Base", "get_session", + "get_session_dependency", "get_engine", "init_db", "close_db", diff --git a/backend/app/db/models/collection.py b/backend/app/db/models/collection.py index f4330d8..0560931 100644 --- a/backend/app/db/models/collection.py +++ b/backend/app/db/models/collection.py @@ -12,7 +12,7 @@ Example: ) """ -from datetime import datetime +from datetime import UTC, datetime from enum import Enum from typing import TYPE_CHECKING from uuid import UUID @@ -111,7 +111,7 @@ class Collection(Base): # When first obtained obtained_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=datetime.utcnow, + default=lambda: datetime.now(UTC), nullable=False, doc="When card was first added to collection", ) diff --git a/backend/app/db/models/game.py b/backend/app/db/models/game.py index 40733b6..a74048e 100644 --- a/backend/app/db/models/game.py +++ b/backend/app/db/models/game.py @@ -25,7 +25,7 @@ Example: ) """ -from datetime import datetime +from datetime import UTC, datetime from enum import Enum from typing import TYPE_CHECKING from uuid import UUID @@ -156,13 +156,13 @@ class ActiveGame(Base): # Timing started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=datetime.utcnow, + default=lambda: datetime.now(UTC), nullable=False, doc="When the game started", ) last_action_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=datetime.utcnow, + default=lambda: datetime.now(UTC), nullable=False, doc="Timestamp of last action", ) @@ -287,7 +287,7 @@ class GameHistory(Base): # Timestamp played_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=datetime.utcnow, + default=lambda: datetime.now(UTC), nullable=False, doc="When the game was completed", ) diff --git a/backend/app/main.py b/backend/app/main.py index 47c861b..04a06ca 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,15 +1,168 @@ -"""Mantimon TCG - FastAPI Application Entry Point.""" +"""Mantimon TCG - FastAPI Application Entry Point. + +This module configures and starts the FastAPI application with: +- Database initialization and cleanup +- Redis connection management +- Card service loading +- CORS middleware +- API routers + +Usage: + uvicorn app.main:app --reload +""" + +import logging +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.config import settings +from app.db import close_db, init_db +from app.db.redis import close_redis, init_redis +from app.services import get_card_service + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Application lifespan context manager. + + Handles startup and shutdown events: + - Startup: Initialize DB, Redis, and load cards + - Shutdown: Close DB and Redis connections + + Args: + app: The FastAPI application instance. + + Yields: + None - Control returns to the application during its lifetime. + """ + # === STARTUP === + logger.info("Starting Mantimon TCG server...") + + # Initialize database connection pool + logger.info("Initializing database...") + await init_db() + logger.info("Database initialized") + + # Initialize Redis connection pool + logger.info("Initializing Redis...") + await init_redis() + logger.info("Redis initialized") + + # Load card definitions into memory + logger.info("Loading card definitions...") + card_service = get_card_service() + await card_service.load_all() + card_count = len(card_service.get_all_cards()) + logger.info(f"Loaded {card_count} card definitions") + + logger.info("Mantimon TCG server started successfully") + + yield # Application runs here + + # === SHUTDOWN === + logger.info("Shutting down Mantimon TCG server...") + + # Close Redis connections + logger.info("Closing Redis connections...") + await close_redis() + logger.info("Redis connections closed") + + # Close database connections + logger.info("Closing database connections...") + await close_db() + logger.info("Database connections closed") + + logger.info("Mantimon TCG server shutdown complete") + + +# Create FastAPI application with lifespan app = FastAPI( title="Mantimon TCG", - description="A home-rule-modified Pokemon Trading Card Game", + description="A home-rule-modified Pokemon Trading Card Game API", version="0.1.0", + lifespan=lifespan, + docs_url="/docs" if settings.is_development else None, + redoc_url="/redoc" if settings.is_development else None, ) +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# === Health Check Endpoints === + @app.get("/health") async def health_check() -> dict[str, str]: - """Health check endpoint.""" + """Basic health check endpoint. + + Returns: + Health status indicating the server is running. + """ return {"status": "healthy"} + + +@app.get("/health/ready") +async def readiness_check() -> dict[str, str | int]: + """Readiness check with service status. + + Verifies that all required services are available: + - Database connection + - Redis connection + - Card service loaded + + Returns: + Detailed status of each service. + """ + from app.db import get_engine + from app.db.redis import get_pool + + status: dict[str, str | int] = {"status": "ready"} + + # Check database + try: + get_engine() # Raises RuntimeError if not initialized + status["database"] = "connected" + except RuntimeError: + status["database"] = "not initialized" + status["status"] = "not ready" + + # Check Redis + try: + get_pool() # Raises RuntimeError if not initialized + status["redis"] = "connected" + except RuntimeError: + status["redis"] = "not initialized" + status["status"] = "not ready" + + # Check card service + card_service = get_card_service() + card_count = len(card_service.get_all_cards()) + if card_count > 0: + status["cards_loaded"] = card_count + else: + status["cards_loaded"] = 0 + status["status"] = "not ready" + + return status + + +# === API Routers === +# TODO: Add routers in Phase 2 +# from app.api import auth, games, cards, decks, campaign +# app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +# app.include_router(cards.router, prefix="/api/cards", tags=["cards"]) +# app.include_router(decks.router, prefix="/api/decks", tags=["decks"]) +# app.include_router(games.router, prefix="/api/games", tags=["games"]) +# app.include_router(campaign.router, prefix="/api/campaign", tags=["campaign"]) diff --git a/backend/app/services/card_service.py b/backend/app/services/card_service.py index 8e135ec..3e7b19d 100644 --- a/backend/app/services/card_service.py +++ b/backend/app/services/card_service.py @@ -41,7 +41,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) # TODO: Update CDN_BASE_URL when CDN is configured -CDN_BASE_URL = "https://cdn.mantimon.com/cards" +CDN_BASE_URL = "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/" class SetInfo(BaseModel): diff --git a/backend/app/services/game_state_manager.py b/backend/app/services/game_state_manager.py index cc08df3..f588aab 100644 --- a/backend/app/services/game_state_manager.py +++ b/backend/app/services/game_state_manager.py @@ -49,6 +49,7 @@ from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.core.models.game_state import GameState from app.db.models import ActiveGame, GameType from app.db.redis import RedisHelper, redis_helper @@ -59,9 +60,6 @@ logger = logging.getLogger(__name__) # Redis key patterns GAME_KEY_PREFIX = "game:" -# Cache TTL for game state (24 hours - games should complete or be cleaned up) -GAME_CACHE_TTL = 60 * 60 * 24 # 24 hours in seconds - class GameStateManager: """Manages game state persistence across Redis and Postgres. @@ -111,7 +109,7 @@ class GameStateManager: """ key = self._game_key(game.game_id) data = game.model_dump(mode="json") - await self.redis.set_json(key, data, expire_seconds=GAME_CACHE_TTL) + await self.redis.set_json(key, data, expire_seconds=settings.game_cache_ttl_seconds) logger.debug(f"Cached game state: {game.game_id}") async def load_from_cache(self, game_id: str) -> GameState | None: diff --git a/backend/scripts/convert_cards.py b/backend/scripts/convert_cards.py index 69c7cd7..89420e3 100644 --- a/backend/scripts/convert_cards.py +++ b/backend/scripts/convert_cards.py @@ -30,7 +30,7 @@ from app.core.enums import CardType, PokemonVariant from app.core.models.card import CardDefinition # TODO: Update CDN_BASE_URL when CDN is configured -CDN_BASE_URL = "https://cdn.mantimon.com/cards" +CDN_BASE_URL = "https://mantipocket.s3.us-east-1.amazonaws.com/card-images/" # Paths SCRIPT_DIR = Path(__file__).parent diff --git a/backend/tests/scripts/test_convert_cards.py b/backend/tests/scripts/test_convert_cards.py index 1c2d526..7d6b40c 100644 --- a/backend/tests/scripts/test_convert_cards.py +++ b/backend/tests/scripts/test_convert_cards.py @@ -226,7 +226,7 @@ class TestTransformPokemonCard: assert len(result["attacks"]) == 1 assert result["illustrator"] == "Narumi Sato" assert result["image_path"] == "pokemon/a1/001-bulbasaur.webp" - assert "cdn.mantimon.com" in result["image_url"] + assert "mantipocket.s3" in result["image_url"] def test_ex_pokemon(self): """Test transforming an EX Pokemon card.