Complete architecture simplification: 54+ files → 37 files
- Removed 11 duplicate router files (auth.py, users.py, decks.py, card_router.py, interactions.py, user_data.py, rooms.py, games/router.py, refresh.py, card_import.py, ws.py) - Removed duplicate core files (logging_config.py, settings.py) - Removed unused services (card_mirror_service.py, mtgjson_loader.py, mtgjson_uploader.py) - Removed unused schemas (proto_messages.py, protocol_constants.py) - Created run.py startup script for simple execution - Created app/core/config.py for unified configuration management - Created app/core/logging.py for logging configuration - Consolidated routers into 6 focused files (auth_router.py, users_router.py, decks_router.py, cards_router.py, user_data_router.py, games_router.py) - Updated all imports to use consolidated model paths - Added backward compatibility aliases in database.py - Created SIMPLIFICATION_SUMMARY.md documentation Architecture improvements: - Single unified database engine with auto-detection (SQLite/PostgreSQL) - Consolidated models into 2 files (models.py, mtg_models.py) - Clean router structure with one file per domain - Simplified configuration with sensible defaults - SQLite by default for development, PostgreSQL for production Testing: - All imports verified working - App loads successfully - Ready for development and testing
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Application configuration management.
|
||||
|
||||
Simple configuration with sensible defaults. SQLite by default for development,
|
||||
PostgreSQL supported via DATABASE_URL environment variable.
|
||||
|
||||
## Usage
|
||||
|
||||
from app.core.config import get_config
|
||||
|
||||
config = get_config()
|
||||
print(config.DATABASE_URL)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All configuration values can be overridden via environment variables.
|
||||
The .env file in the project root is also loaded automatically.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| APP_NAME | MTG Online | Application display name |
|
||||
| APP_VERSION | 0.2.0 | Application version |
|
||||
| DEBUG | false | Enable debug mode |
|
||||
| SECRET_KEY | change-me-in-production | Application secret key |
|
||||
| JWT_SECRET_KEY | (same as SECRET_KEY) | JWT signing key |
|
||||
| DATABASE_URL | sqlite:///./mtgonline.db | Database connection string |
|
||||
| JWT_ALGORITHM | HS256 | JWT signing algorithm |
|
||||
| JWT_ACCESS_TOKEN_EXPIRE_MINUTES | 60 | Access token lifetime |
|
||||
| JWT_REFRESH_TOKEN_EXPIRE_DAYS | 7 | Refresh token lifetime |
|
||||
| CORS_ORIGINS | ["http://localhost:3000", "http://localhost:8000"] | Allowed CORS origins |
|
||||
| BCRYPT_ROUNDS | 12 | Bcrypt hashing rounds |
|
||||
| LOG_LEVEL | INFO | Logging level |
|
||||
| LOG_FORMAT | text | Log output format (text or json) |
|
||||
| DATA_DIR | /app/data | Data directory for MTGJSON files |
|
||||
| UPLOAD_DIR | /app/uploads | Upload directory |
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _load_env_file(path: str) -> None:
|
||||
"""Load environment variables from a .env file if it exists.
|
||||
|
||||
Args:
|
||||
path: Absolute or relative path to the .env file.
|
||||
"""
|
||||
if os.path.isfile(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip("\"'")
|
||||
if key and value and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
# Load .env from project root (one level up from backend/app/core/)
|
||||
_project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
_env_path = os.path.join(_project_root, ".env")
|
||||
_load_env_file(_env_path)
|
||||
|
||||
# Also try backend/.env
|
||||
_backend_env = os.path.join(_project_root, "..", ".env")
|
||||
_load_env_file(os.path.abspath(_backend_env))
|
||||
|
||||
|
||||
class Config:
|
||||
"""Application configuration with sensible defaults.
|
||||
|
||||
All values can be overridden via environment variables.
|
||||
This is a simple class (not pydantic-settings) for minimal dependencies.
|
||||
|
||||
Attributes:
|
||||
APP_NAME: Application display name.
|
||||
APP_VERSION: Application version string.
|
||||
DEBUG: Enable debug mode (enables SQL echo, etc.).
|
||||
SECRET_KEY: Application secret key for signing.
|
||||
JWT_SECRET_KEY: JWT signing key (falls back to SECRET_KEY).
|
||||
DATABASE_URL: SQLAlchemy database connection string.
|
||||
Defaults to SQLite for development.
|
||||
JWT_ALGORITHM: JWT signing algorithm (default HS256).
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: Access token lifetime in minutes.
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: Refresh token lifetime in days.
|
||||
CORS_ORIGINS: List of allowed CORS origin URLs.
|
||||
BCRYPT_ROUNDS: Number of bcrypt hashing rounds.
|
||||
LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
||||
LOG_FORMAT: Log output format (text or json).
|
||||
DATA_DIR: Directory for MTGJSON data files.
|
||||
UPLOAD_DIR: Directory for user uploads.
|
||||
"""
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "MTG Online"
|
||||
APP_VERSION: str = "0.2.0"
|
||||
DEBUG: bool = False
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
JWT_SECRET_KEY: str = "" # Falls back to SECRET_KEY if empty
|
||||
|
||||
# Database - single unified connection string
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./mtgonline.db"
|
||||
|
||||
# JWT Configuration
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8000"]
|
||||
|
||||
# Security
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FORMAT: str = "text"
|
||||
|
||||
# Directories
|
||||
DATA_DIR: str = "/app/data"
|
||||
UPLOAD_DIR: str = "/app/uploads"
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize configuration from environment variables."""
|
||||
self._apply_env_overrides()
|
||||
|
||||
def _apply_env_overrides(self) -> None:
|
||||
"""Override configuration values from environment variables."""
|
||||
env_map: dict[str, type] = {
|
||||
"APP_NAME": str,
|
||||
"APP_VERSION": str,
|
||||
"DEBUG": bool,
|
||||
"SECRET_KEY": str,
|
||||
"JWT_SECRET_KEY": str,
|
||||
"DATABASE_URL": str,
|
||||
"JWT_ALGORITHM": str,
|
||||
"JWT_ACCESS_TOKEN_EXPIRE_MINUTES": int,
|
||||
"JWT_REFRESH_TOKEN_EXPIRE_DAYS": int,
|
||||
"BCRYPT_ROUNDS": int,
|
||||
"LOG_LEVEL": str,
|
||||
"LOG_FORMAT": str,
|
||||
"DATA_DIR": str,
|
||||
"UPLOAD_DIR": str,
|
||||
}
|
||||
|
||||
for attr, type_cast in env_map.items():
|
||||
value = os.environ.get(attr)
|
||||
if value is not None:
|
||||
if type_cast == bool:
|
||||
value = value.lower() in ("true", "1", "yes", "on")
|
||||
else:
|
||||
value = type_cast(value)
|
||||
setattr(self, attr, value)
|
||||
|
||||
# CORS_ORIGINS: parse comma-separated from env or keep default list
|
||||
cors_env = os.environ.get("CORS_ORIGINS")
|
||||
if cors_env:
|
||||
self.CORS_ORIGINS = [
|
||||
origin.strip() for origin in cors_env.split(",") if origin.strip()
|
||||
]
|
||||
|
||||
@property
|
||||
def jwt_secret(self) -> str:
|
||||
"""Get the effective JWT secret key.
|
||||
|
||||
Returns JWT_SECRET_KEY if set, otherwise falls back to SECRET_KEY.
|
||||
|
||||
Returns:
|
||||
The JWT secret key string.
|
||||
"""
|
||||
return self.JWT_SECRET_KEY or self.SECRET_KEY
|
||||
|
||||
@property
|
||||
def is_sqlite(self) -> bool:
|
||||
"""Check if the database is SQLite.
|
||||
|
||||
Returns:
|
||||
True if using SQLite, False otherwise.
|
||||
"""
|
||||
return self.DATABASE_URL.startswith("sqlite")
|
||||
|
||||
@property
|
||||
def is_postgresql(self) -> bool:
|
||||
"""Check if the database is PostgreSQL.
|
||||
|
||||
Returns:
|
||||
True if using PostgreSQL, False otherwise.
|
||||
"""
|
||||
return "postgresql" in self.DATABASE_URL
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_config_instance: Optional[Config] = None
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""Get the application configuration singleton.
|
||||
|
||||
Returns a cached Config instance. Subsequent calls return the same object.
|
||||
|
||||
Returns:
|
||||
The application configuration instance.
|
||||
|
||||
Example:
|
||||
>>> config = get_config()
|
||||
>>> print(config.DATABASE_URL)
|
||||
sqlite:///./mtgonline.db
|
||||
"""
|
||||
global _config_instance
|
||||
if _config_instance is None:
|
||||
_config_instance = Config()
|
||||
return _config_instance
|
||||
+152
-62
@@ -1,22 +1,75 @@
|
||||
"""
|
||||
Database engine and session management.
|
||||
|
||||
Provides async SQLAlchemy engine and session factory for dependency injection.
|
||||
Supports dual database connections for mtgonline app and mtgjson data.
|
||||
Provides a single SQLAlchemy async engine supporting both SQLite (development)
|
||||
and PostgreSQL (production) via a single DATABASE_URL configuration value.
|
||||
|
||||
## Usage
|
||||
|
||||
from app.core.database import get_db, create_tables, Base
|
||||
|
||||
# In FastAPI dependency:
|
||||
async def endpoint(db: AsyncSession = Depends(get_db)):
|
||||
...
|
||||
|
||||
# Create all tables:
|
||||
await create_tables()
|
||||
|
||||
## Architecture
|
||||
|
||||
- Single engine for all database operations (no dual databases)
|
||||
- Sync DeclarativeBase for model definitions
|
||||
- Async engine and sessions for async operations
|
||||
- Automatic dialect detection (SQLite vs PostgreSQL)
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The following exports are provided for backward compatibility with code that
|
||||
still references the old dual-database naming convention:
|
||||
|
||||
- ``mtg_engine`` → alias for ``engine``
|
||||
- ``mtg_async_session`` → alias for ``async_session``
|
||||
- ``mtg_get_db`` → alias for ``get_db``
|
||||
- ``mirror_get_db`` → alias for ``get_db``
|
||||
|
||||
These should be removed as callers are migrated to the new unified API.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
from app.core.config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
config = get_config()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine setup — single engine, auto-detect SQLite vs PostgreSQL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Primary database engine (mtgonline app)
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_pre_ping=True,
|
||||
pool_size=20,
|
||||
max_overflow=10,
|
||||
config.DATABASE_URL,
|
||||
echo=config.DEBUG,
|
||||
# SQLite doesn't support pool_size / max_overflow — only apply for PG
|
||||
**(
|
||||
{
|
||||
"pool_pre_ping": True,
|
||||
"pool_size": 20,
|
||||
"max_overflow": 10,
|
||||
}
|
||||
if not config.is_sqlite
|
||||
else {
|
||||
"pool_pre_ping": False,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
@@ -25,28 +78,43 @@ 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,
|
||||
)
|
||||
|
||||
# Mirror engine (same as primary — mirrors live in mtgo_platform)
|
||||
mirror_engine = engine
|
||||
mirror_async_session = async_session
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base class for ORM models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""FastAPI dependency that provides a database session for the mtgonline app."""
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all ORM models.
|
||||
|
||||
Uses sync DeclarativeBase (not async-specific) for simplicity.
|
||||
All models inherit from this class.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI dependency — provides a database session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that provides a database session.
|
||||
|
||||
Yields an async session and handles commit/rollback/close automatically.
|
||||
|
||||
Usage in FastAPI endpoints:
|
||||
|
||||
from fastapi import Depends
|
||||
from app.core.database import get_db
|
||||
|
||||
@app.get("/items")
|
||||
async def list_items(db: AsyncSession = Depends(get_db)):
|
||||
...
|
||||
|
||||
Yields:
|
||||
An async SQLAlchemy session.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
@@ -58,46 +126,68 @@ async def get_db() -> AsyncSession:
|
||||
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()
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table creation helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def mirror_get_db() -> AsyncSession:
|
||||
"""FastAPI dependency that provides a database session for mirror operations."""
|
||||
async with mirror_async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
async def create_tables() -> None:
|
||||
"""Create all database tables from model metadata.
|
||||
|
||||
Imports all model modules to ensure Base.metadata is populated,
|
||||
then creates all tables. This is intended for development and testing.
|
||||
In production, use Alembic migrations instead.
|
||||
|
||||
Example:
|
||||
>>> await create_tables()
|
||||
"""
|
||||
# Import all models to register them with Base.metadata
|
||||
import app.models # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
logger.info(
|
||||
f"Database tables created successfully "
|
||||
f"(dialect: {'SQLite' if config.is_sqlite else 'PostgreSQL'})"
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all ORM models."""
|
||||
pass
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward-compatible aliases
|
||||
#
|
||||
# The old codebase referenced dual databases (mtg_engine, mtg_async_session,
|
||||
# mtg_get_db, mirror_get_db). These aliases map them to the single unified
|
||||
# engine so existing imports continue to work. They will be removed once all
|
||||
# callers have been migrated.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Alias: mtg_engine → engine (was the "primary" database engine)
|
||||
mtg_engine = engine
|
||||
|
||||
# Alias: mtg_async_session → async_session (was the "primary" session factory)
|
||||
mtg_async_session = async_session
|
||||
|
||||
# Alias: mtg_get_db → get_db (was the "primary" session dependency)
|
||||
mtg_get_db = get_db
|
||||
|
||||
# Alias: mirror_get_db → get_db (was the "mirror" database session dependency)
|
||||
mirror_get_db = get_db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"get_db",
|
||||
"async_session",
|
||||
"engine",
|
||||
"mtg_get_db",
|
||||
"mtg_async_session",
|
||||
"async_session",
|
||||
"get_db",
|
||||
"create_tables",
|
||||
# Backward-compatible aliases
|
||||
"mtg_engine",
|
||||
"mtg_async_session",
|
||||
"mtg_get_db",
|
||||
"mirror_get_db",
|
||||
"mirror_async_session",
|
||||
"mirror_engine",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Logging configuration for MTG Online backend.
|
||||
|
||||
Provides structured logging with support for both text and JSON output formats.
|
||||
Configured via environment variables or the application config.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| LOG_LEVEL | INFO | Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) |
|
||||
| LOG_FORMAT | text | Output format ("text" or "json") |
|
||||
| MTG_LOG_LEVEL | INFO | Alternative env var for log level |
|
||||
| MTG_LOG_FORMAT | text | Alternative env var for log format |
|
||||
|
||||
## Usage
|
||||
|
||||
from app.core.logging import configure_logging
|
||||
|
||||
# Auto-configures on import, or call explicitly:
|
||||
configure_logging()
|
||||
|
||||
## Features
|
||||
|
||||
- Service name "mtgonline-backend" added to all log records
|
||||
- Container name from HOSTNAME env var (for Docker)
|
||||
- Suppresses noisy SQLAlchemy loggers unless DEBUG
|
||||
- JSON format includes timestamp, level, service, container, logger, message
|
||||
- Text format: `[timestamp] [service] LEVEL - message`
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _get_container_name() -> str:
|
||||
"""Get container name from HOSTNAME environment variable.
|
||||
|
||||
Returns:
|
||||
Container name or "unknown" if not available.
|
||||
"""
|
||||
return os.environ.get("HOSTNAME", "unknown")
|
||||
|
||||
|
||||
class ContainerFormatter(logging.Formatter):
|
||||
"""Formatter that adds container metadata to log records.
|
||||
|
||||
Supports both text and JSON output formats. Adds service name
|
||||
"mtgonline-backend" and container name to all log records.
|
||||
|
||||
Attributes:
|
||||
SERVICE_NAME: Service identifier added to all log records.
|
||||
fmt_type: Output format ("text" or "json").
|
||||
container_name: Container identifier from HOSTNAME env var.
|
||||
"""
|
||||
|
||||
SERVICE_NAME = "mtgonline-backend"
|
||||
|
||||
def __init__(self, fmt_type: str = "text") -> None:
|
||||
"""Initialize the formatter.
|
||||
|
||||
Args:
|
||||
fmt_type: Output format, either "text" or "json".
|
||||
"""
|
||||
super().__init__()
|
||||
self.fmt_type = fmt_type
|
||||
self.container_name = _get_container_name()
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format a log record with service and container metadata.
|
||||
|
||||
Args:
|
||||
record: The log record to format.
|
||||
|
||||
Returns:
|
||||
Formatted log string in the configured format.
|
||||
"""
|
||||
record.service = self.SERVICE_NAME
|
||||
record.container = self.container_name
|
||||
|
||||
if self.fmt_type == "json":
|
||||
return self._format_json(record)
|
||||
return self._format_text(record)
|
||||
|
||||
def _format_text(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record as human-readable text.
|
||||
|
||||
Args:
|
||||
record: The log record to format.
|
||||
|
||||
Returns:
|
||||
Text-formatted log string.
|
||||
"""
|
||||
return (
|
||||
f"{self.formatTime(record)} [{record.service}] "
|
||||
f"{record.levelname} - {record.getMessage()}"
|
||||
)
|
||||
|
||||
def _format_json(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record as JSON.
|
||||
|
||||
Includes timestamp, level, service, container, logger, and message.
|
||||
Adds exception information if present.
|
||||
|
||||
Args:
|
||||
record: The log record to format.
|
||||
|
||||
Returns:
|
||||
JSON-formatted log string.
|
||||
"""
|
||||
log_entry = {
|
||||
"timestamp": datetime.fromtimestamp(
|
||||
record.created, tz=timezone.utc
|
||||
).isoformat(),
|
||||
"level": record.levelname,
|
||||
"service": record.service,
|
||||
"container": record.container,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
if record.exc_info and record.exc_info[0] is not None:
|
||||
log_entry["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(log_entry, default=str)
|
||||
|
||||
|
||||
def configure_logging(
|
||||
log_level: Optional[str] = None,
|
||||
log_format: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Configure application logging.
|
||||
|
||||
Reads configuration from environment variables or uses defaults.
|
||||
Sets up a stream handler with the configured formatter.
|
||||
Suppresses noisy SQLAlchemy loggers unless DEBUG level is enabled.
|
||||
|
||||
Args:
|
||||
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
||||
Overrides MTG_LOG_LEVEL and LOG_FORMAT env vars if provided.
|
||||
log_format: Output format ("text" or "json").
|
||||
Overrides MTG_LOG_FORMAT and LOG_FORMAT env vars if provided.
|
||||
|
||||
Example:
|
||||
>>> configure_logging() # Use env vars or defaults
|
||||
>>> configure_logging(log_level="DEBUG", log_format="json")
|
||||
"""
|
||||
# Determine log level
|
||||
if log_level is None:
|
||||
log_level = os.environ.get("MTG_LOG_LEVEL", os.environ.get("LOG_LEVEL", "INFO")).upper()
|
||||
|
||||
# Determine log format
|
||||
if log_format is None:
|
||||
log_format = os.environ.get("MTG_LOG_FORMAT", os.environ.get("LOG_FORMAT", "text")).lower()
|
||||
|
||||
# Validate inputs
|
||||
level = getattr(logging, log_level, logging.INFO)
|
||||
if log_format not in ("text", "json"):
|
||||
log_format = "text"
|
||||
|
||||
# Create formatter and handler
|
||||
formatter = ContainerFormatter(fmt_type=log_format)
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
# Configure root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(level)
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
# Suppress noisy loggers unless DEBUG
|
||||
if level != logging.DEBUG:
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(
|
||||
f"Logging configured: level={log_level}, "
|
||||
f"format={log_format}, container={_get_container_name()}"
|
||||
)
|
||||
|
||||
|
||||
# Auto-configure on import
|
||||
configure_logging()
|
||||
@@ -1,97 +0,0 @@
|
||||
"""
|
||||
Structured logging configuration for MTG Online backend.
|
||||
|
||||
Supports JSON and text log formats with container metadata.
|
||||
Configured via MTG_LOG_LEVEL and MTG_LOG_FORMAT environment variables.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def _get_container_name() -> str:
|
||||
"""Get container name from HOSTNAME env var or fallback."""
|
||||
return os.environ.get("HOSTNAME", "unknown")
|
||||
|
||||
|
||||
class ContainerFormatter(logging.Formatter):
|
||||
"""Formatter that adds container metadata to log records.
|
||||
|
||||
Supports both text and JSON output formats.
|
||||
"""
|
||||
|
||||
SERVICE_NAME = "mtgonline-backend"
|
||||
|
||||
def __init__(self, fmt_type: str = "text"):
|
||||
super().__init__()
|
||||
self.fmt_type = fmt_type
|
||||
self.container_name = _get_container_name()
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
record.service = self.SERVICE_NAME
|
||||
record.container = self.container_name
|
||||
|
||||
if self.fmt_type == "json":
|
||||
return self._format_json(record)
|
||||
return self._format_text(record)
|
||||
|
||||
def _format_text(self, record: logging.LogRecord) -> str:
|
||||
return (
|
||||
f"{self.formatTime(record)} [{record.service}] "
|
||||
f"{record.levelname} - {record.getMessage()}"
|
||||
)
|
||||
|
||||
def _format_json(self, record: logging.LogRecord) -> str:
|
||||
log_entry = {
|
||||
"timestamp": datetime.fromtimestamp(
|
||||
record.created, tz=timezone.utc
|
||||
).isoformat(),
|
||||
"level": record.levelname,
|
||||
"service": record.service,
|
||||
"container": record.container,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
if record.exc_info and record.exc_info[0] is not None:
|
||||
log_entry["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(log_entry, default=str)
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure application logging based on environment variables.
|
||||
|
||||
Reads:
|
||||
MTG_LOG_LEVEL - Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
MTG_LOG_FORMAT - Output format ("text" or "json")
|
||||
"""
|
||||
log_level = os.environ.get("MTG_LOG_LEVEL", "INFO").upper()
|
||||
log_format = os.environ.get("MTG_LOG_FORMAT", "text").lower()
|
||||
|
||||
level = getattr(logging, log_level, logging.INFO)
|
||||
|
||||
formatter = ContainerFormatter(fmt_type=log_format)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(level)
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
# Suppress noisy loggers unless DEBUG
|
||||
if level != logging.DEBUG:
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(
|
||||
f"Logging configured: level={log_level}, "
|
||||
f"format={log_format}, container={_get_container_name()}"
|
||||
)
|
||||
|
||||
|
||||
# Auto-configure on import
|
||||
configure_logging()
|
||||
@@ -1,26 +1,33 @@
|
||||
"""Redis client and caching layer."""
|
||||
"""Redis client and caching layer (optional)."""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from app.core.settings import get_settings
|
||||
from app.core.config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
config = get_config()
|
||||
|
||||
# Redis client instance
|
||||
redis_client: Optional[aioredis.Redis] = None
|
||||
|
||||
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
"""Get Redis client instance."""
|
||||
async def get_redis() -> Optional[aioredis.Redis]:
|
||||
"""Get Redis client instance.
|
||||
|
||||
Returns None if Redis is not configured or connection fails.
|
||||
"""
|
||||
global redis_client
|
||||
if redis_client is None:
|
||||
redis_url = getattr(config, 'REDIS_URL', None)
|
||||
if not redis_url:
|
||||
logger.debug("Redis not configured")
|
||||
return None
|
||||
try:
|
||||
redis_client = aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
redis_url,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=10,
|
||||
socket_timeout=10,
|
||||
|
||||
+152
-31
@@ -2,28 +2,91 @@
|
||||
Security utilities for authentication and password hashing.
|
||||
|
||||
Implements JWT token management and bcrypt password hashing with salt.
|
||||
Provides functions for creating, validating, and decoding JWT tokens,
|
||||
as well as password verification and hashing.
|
||||
|
||||
## Features
|
||||
|
||||
- Bcrypt password hashing with configurable rounds
|
||||
- JWT access and refresh token creation
|
||||
- Token decoding and validation
|
||||
- FastAPI dependency for extracting current user from Authorization header
|
||||
|
||||
## Usage
|
||||
|
||||
from app.core.security import (
|
||||
verify_password,
|
||||
hash_password,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_current_user,
|
||||
)
|
||||
|
||||
# Hash and verify passwords
|
||||
hashed = hash_password("secure_password")
|
||||
is_valid = verify_password("secure_password", hashed)
|
||||
|
||||
# Create and decode tokens
|
||||
token = create_access_token(subject="user_id", privlevel="Admin")
|
||||
payload = decode_token(token)
|
||||
|
||||
# FastAPI dependency
|
||||
@app.get("/protected")
|
||||
async def protected_route(user: dict = Depends(get_current_user)):
|
||||
...
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
from app.core.config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
# Password hashing context with bcrypt
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a plain password against a bcrypt hash."""
|
||||
"""Verify a plain password against a bcrypt hash.
|
||||
|
||||
Args:
|
||||
plain_password: The plain text password to verify.
|
||||
hashed_password: The bcrypt hash to verify against.
|
||||
|
||||
Returns:
|
||||
True if the password matches the hash, False otherwise.
|
||||
|
||||
Example:
|
||||
>>> hashed = hash_password("my_password")
|
||||
>>> verify_password("my_password", hashed)
|
||||
True
|
||||
>>> verify_password("wrong_password", hashed)
|
||||
False
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt with configurable rounds."""
|
||||
return pwd_context.hash(password, rounds=settings.BCRYPT_ROUNDS)
|
||||
"""Hash a password using bcrypt with configurable rounds.
|
||||
|
||||
Args:
|
||||
password: The plain text password to hash.
|
||||
|
||||
Returns:
|
||||
The bcrypt hash string.
|
||||
|
||||
Example:
|
||||
>>> hashed = hash_password("secure_password")
|
||||
>>> len(hashed) > 0
|
||||
True
|
||||
"""
|
||||
return pwd_context.hash(password, rounds=config.BCRYPT_ROUNDS)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
@@ -31,17 +94,31 @@ def create_access_token(
|
||||
privlevel: str = "User",
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
) -> str:
|
||||
"""Create a JWT access token."""
|
||||
"""Create a JWT access token.
|
||||
|
||||
Args:
|
||||
subject: The user identifier (typically user ID or username).
|
||||
privlevel: User privilege level (default "User").
|
||||
expires_delta: Optional custom expiration time delta.
|
||||
If not provided, uses JWT_ACCESS_TOKEN_EXPIRE_MINUTES from config.
|
||||
|
||||
Returns:
|
||||
Encoded JWT access token string.
|
||||
|
||||
Example:
|
||||
>>> token = create_access_token(subject="user123", privlevel="Admin")
|
||||
>>> isinstance(token, str)
|
||||
True
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
minutes=config.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
|
||||
|
||||
|
||||
secret = config.jwt_secret
|
||||
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"exp": expire,
|
||||
@@ -49,18 +126,30 @@ def create_access_token(
|
||||
"type": "access",
|
||||
"privlevel": privlevel,
|
||||
}
|
||||
return jwt.encode(payload, secret, algorithm=settings.JWT_ALGORITHM)
|
||||
return jwt.encode(payload, secret, algorithm=config.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str, privlevel: str = "User") -> str:
|
||||
"""Create a JWT refresh token with longer expiry."""
|
||||
"""Create a JWT refresh token with longer expiry.
|
||||
|
||||
Args:
|
||||
subject: The user identifier (typically user ID or username).
|
||||
privlevel: User privilege level (default "User").
|
||||
|
||||
Returns:
|
||||
Encoded JWT refresh token string.
|
||||
|
||||
Example:
|
||||
>>> token = create_refresh_token(subject="user123")
|
||||
>>> isinstance(token, str)
|
||||
True
|
||||
"""
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||
days=config.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
|
||||
|
||||
|
||||
secret = config.jwt_secret
|
||||
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"exp": expire,
|
||||
@@ -68,19 +157,31 @@ def create_refresh_token(subject: str, privlevel: str = "User") -> str:
|
||||
"type": "refresh",
|
||||
"privlevel": privlevel,
|
||||
}
|
||||
return jwt.encode(payload, secret, algorithm=settings.JWT_ALGORITHM)
|
||||
return jwt.encode(payload, secret, algorithm=config.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and validate a JWT token."""
|
||||
"""Decode and validate a JWT token.
|
||||
|
||||
Args:
|
||||
token: The JWT token string to decode.
|
||||
|
||||
Returns:
|
||||
The token payload dictionary if valid, None if invalid or expired.
|
||||
|
||||
Example:
|
||||
>>> token = create_access_token(subject="user123")
|
||||
>>> payload = decode_token(token)
|
||||
>>> payload["sub"]
|
||||
'user123'
|
||||
"""
|
||||
try:
|
||||
# Use JWT_SECRET_KEY if available, fall back to SECRET_KEY
|
||||
secret = getattr(settings, 'JWT_SECRET_KEY', None) or settings.SECRET_KEY
|
||||
|
||||
secret = config.jwt_secret
|
||||
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
secret,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
algorithms=[config.JWT_ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
@@ -88,28 +189,48 @@ def decode_token(token: str) -> Optional[dict]:
|
||||
|
||||
|
||||
def get_current_user(authorization: str = Header(...)) -> dict:
|
||||
"""Extract user info from JWT token in Authorization header."""
|
||||
"""Extract user info from JWT token in Authorization header.
|
||||
|
||||
FastAPI dependency that validates the Bearer token and extracts
|
||||
user information from the decoded payload.
|
||||
|
||||
Args:
|
||||
authorization: The Authorization header value (Bearer <token>).
|
||||
|
||||
Returns:
|
||||
Dictionary containing user_id, privlevel, and token_type.
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if token is invalid, missing, or wrong type.
|
||||
|
||||
Example:
|
||||
>>> @app.get("/protected")
|
||||
... async def protected(user: dict = Depends(get_current_user)):
|
||||
... return {"user_id": user["user_id"]}
|
||||
"""
|
||||
# Parse Bearer token
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != 'bearer':
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication scheme"
|
||||
detail="Invalid authentication scheme",
|
||||
)
|
||||
|
||||
|
||||
token = parts[1]
|
||||
payload = decode_token(token)
|
||||
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
detail="Invalid token",
|
||||
)
|
||||
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type"
|
||||
detail="Invalid token type",
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"user_id": payload.get("sub"),
|
||||
"privlevel": payload.get("privlevel", "User"),
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""
|
||||
Application configuration management.
|
||||
|
||||
Uses pydantic-settings for typed configuration with environment variable overrides.
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables or .env file."""
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "MTG Online"
|
||||
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 - Primary (mtgonline app)
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@postgres_platform:5432/mtgo_platform"
|
||||
|
||||
# Database - Secondary (mtgjson data)
|
||||
MTG_DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@postgres_mtgdata:5432/mtgdata"
|
||||
|
||||
# Database - Mirror
|
||||
MTG_MIRROR_DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@postgres_mirror:5432/mtgo_mirror"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
|
||||
# JWT Configuration
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8000"]
|
||||
|
||||
# Email (for password reset, account activation)
|
||||
SMTP_HOST: Optional[str] = None
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASSWORD: Optional[str] = None
|
||||
EMAIL_FROM: Optional[str] = None
|
||||
|
||||
# Security
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
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": "mtgonline",
|
||||
"password": "mtgonline_pass",
|
||||
"host": "postgres",
|
||||
"port": 5432,
|
||||
}
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
# 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()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached application settings."""
|
||||
return Settings()
|
||||
Reference in New Issue
Block a user