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:
2026-08-26 15:15:36 +00:00
parent 03f5433583
commit 900f51af3a
45 changed files with 5823 additions and 5460 deletions
+216
View File
@@ -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
View File
@@ -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",
]
+187
View File
@@ -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()
-97
View File
@@ -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()
+13 -6
View File
@@ -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
View File
@@ -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"),
-86
View File
@@ -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()