mantimon-tcg/backend/app/config.py
Cal Corum 50684a1b11 Add database infrastructure with SQLAlchemy models and test suite
Phase 1 Database Implementation (DB-001 through DB-012):

Models:
- User: OAuth support (Google/Discord), premium subscriptions
- Collection: Card ownership with CardSource enum
- Deck: JSONB cards/energy_cards, validation state
- CampaignProgress: One-to-one with User, medals/NPCs as JSONB
- ActiveGame: In-progress games with GameType enum
- GameHistory: Completed games with EndReason enum, replay data

Infrastructure:
- Alembic migrations with sync psycopg2 (avoids async issues)
- Docker Compose for Postgres (5433) and Redis (6380)
- App config with Pydantic settings
- Redis client helper

Test Infrastructure:
- 68 database tests (47 model + 21 relationship)
- Async factory pattern for test data creation
- Sync TRUNCATE cleanup (solves pytest-asyncio event loop mismatch)
- Uses dev containers instead of testcontainers for reliability

Key technical decisions:
- passive_deletes=True for ON DELETE SET NULL relationships
- NullPool for test sessions (no connection reuse)
- expire_on_commit=False with manual expire() for relationship tests
2026-01-27 10:17:30 -06:00

218 lines
6.0 KiB
Python

"""Application configuration using Pydantic Settings.
This module provides environment-based configuration for the Mantimon TCG backend.
Configuration values can be set via environment variables or .env files.
Environment Detection:
- dev: Local development (default)
- staging: Pre-production testing
- prod: Production environment
Example .env file:
ENVIRONMENT=dev
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/mantimon
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=your-secret-key-here
Usage:
from app.config import settings
print(settings.database_url)
print(settings.is_production)
"""
from functools import lru_cache
from typing import Literal
from pydantic import Field, PostgresDsn, RedisDsn, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables.
All settings can be overridden via environment variables.
Prefix: None (direct variable names)
Attributes:
environment: Current environment (dev/staging/prod).
debug: Enable debug mode (auto-set based on environment).
database_url: PostgreSQL connection URL.
database_pool_size: Connection pool size.
database_max_overflow: Max overflow connections.
redis_url: Redis connection URL.
redis_max_connections: Max Redis connections.
secret_key: Secret key for JWT signing.
jwt_algorithm: JWT signing algorithm.
jwt_expire_minutes: JWT token expiration in minutes.
google_client_id: Google OAuth client ID.
google_client_secret: Google OAuth client secret.
discord_client_id: Discord OAuth client ID.
discord_client_secret: Discord OAuth client secret.
cors_origins: Allowed CORS origins.
"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Environment
environment: Literal["dev", "staging", "prod"] = Field(
default="dev",
description="Current environment",
)
debug: bool = Field(
default=False,
description="Enable debug mode",
)
# Database (PostgreSQL)
database_url: PostgresDsn = Field(
default="postgresql+asyncpg://mantimon:mantimon@localhost:5433/mantimon",
description="PostgreSQL connection URL",
)
database_pool_size: int = Field(
default=5,
ge=1,
le=50,
description="Database connection pool size",
)
database_max_overflow: int = Field(
default=10,
ge=0,
le=100,
description="Max overflow connections beyond pool size",
)
database_echo: bool = Field(
default=False,
description="Echo SQL statements (for debugging)",
)
# Redis
redis_url: RedisDsn = Field(
default="redis://localhost:6380/0",
description="Redis connection URL",
)
redis_max_connections: int = Field(
default=10,
ge=1,
le=100,
description="Max Redis connections in pool",
)
# Security
secret_key: SecretStr = Field(
default="dev-secret-key-change-in-production",
description="Secret key for JWT and other cryptographic operations",
)
jwt_algorithm: str = Field(
default="HS256",
description="JWT signing algorithm",
)
jwt_expire_minutes: int = Field(
default=30,
ge=1,
description="JWT access token expiration in minutes",
)
jwt_refresh_expire_days: int = Field(
default=7,
ge=1,
description="JWT refresh token expiration in days",
)
# OAuth - Google
google_client_id: str | None = Field(
default=None,
description="Google OAuth 2.0 client ID",
)
google_client_secret: SecretStr | None = Field(
default=None,
description="Google OAuth 2.0 client secret",
)
# OAuth - Discord
discord_client_id: str | None = Field(
default=None,
description="Discord OAuth 2.0 client ID",
)
discord_client_secret: SecretStr | None = Field(
default=None,
description="Discord OAuth 2.0 client secret",
)
# CORS
cors_origins: list[str] = Field(
default=["http://localhost:3000", "http://localhost:5173"],
description="Allowed CORS origins",
)
# Game Settings
turn_timeout_seconds: int = Field(
default=120,
ge=30,
le=600,
description="Turn timeout in seconds (2 minutes default)",
)
# Card Data
card_data_path: str = Field(
default="data/cards",
description="Path to bundled card JSON files",
)
@field_validator("debug", mode="before")
@classmethod
def set_debug_from_environment(cls, v: bool, info) -> bool:
"""Auto-enable debug in dev environment if not explicitly set."""
if v is not None:
return v
env = info.data.get("environment", "dev")
return env == "dev"
@property
def is_production(self) -> bool:
"""Check if running in production environment."""
return self.environment == "prod"
@property
def is_development(self) -> bool:
"""Check if running in development environment."""
return self.environment == "dev"
@property
def database_url_sync(self) -> str:
"""Get synchronous database URL (for Alembic migrations).
Converts asyncpg:// to psycopg2:// for sync operations.
"""
url = str(self.database_url)
return url.replace("postgresql+asyncpg://", "postgresql://")
@lru_cache
def get_settings() -> Settings:
"""Get cached settings instance.
Uses lru_cache to ensure settings are only loaded once.
Returns:
Settings instance loaded from environment.
Example:
settings = get_settings()
print(settings.database_url)
"""
return Settings()
# Convenience alias for direct import
settings = get_settings()