feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
100
main.py
100
main.py
@@ -7,6 +7,18 @@ Story 3.5: API Versioning - All endpoints under /api/v1/ prefix
|
||||
Story 3.6: Documentation OpenAPI (Swagger + ReDoc)
|
||||
"""
|
||||
|
||||
# Story 6.6: Fail-fast on missing required env before any other imports (NFR10)
|
||||
import sys
|
||||
from config import config
|
||||
_missing = config.validate_required_env()
|
||||
if _missing:
|
||||
msg = (
|
||||
"Missing required env: " + ", ".join(_missing)
|
||||
+ ". Set them in .env or environment. See .env.example."
|
||||
)
|
||||
print(msg, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
from fastapi import (
|
||||
FastAPI,
|
||||
Request,
|
||||
@@ -19,10 +31,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import os
|
||||
|
||||
from config import config
|
||||
from translators import (
|
||||
excel_translator,
|
||||
word_translator,
|
||||
@@ -60,26 +69,29 @@ from utils.exceptions import (
|
||||
LanguageNotSupportedError,
|
||||
DocumentProcessingError as UtilsDocumentProcessingError,
|
||||
)
|
||||
from core.logging import configure_logging, get_logger
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, os.getenv("LOG_LEVEL", "INFO")),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
# Configure structlog-based logging once at startup (single source of truth: config).
|
||||
_json_logs = (
|
||||
config.LOG_FORMAT.lower() == "json" or config.ENV == "production"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
configure_logging(
|
||||
json_logs=_json_logs,
|
||||
log_level=config.LOG_LEVEL,
|
||||
)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
rate_limit_config = RateLimitConfig(
|
||||
requests_per_minute=int(os.getenv("RATE_LIMIT_PER_MINUTE", "30")),
|
||||
requests_per_hour=int(os.getenv("RATE_LIMIT_PER_HOUR", "200")),
|
||||
translations_per_minute=int(os.getenv("TRANSLATIONS_PER_MINUTE", "10")),
|
||||
translations_per_hour=int(os.getenv("TRANSLATIONS_PER_HOUR", "50")),
|
||||
max_concurrent_translations=int(os.getenv("MAX_CONCURRENT_TRANSLATIONS", "5")),
|
||||
requests_per_minute=config.RATE_LIMIT_PER_MINUTE,
|
||||
requests_per_hour=config.RATE_LIMIT_PER_HOUR,
|
||||
translations_per_minute=config.TRANSLATIONS_PER_MINUTE,
|
||||
translations_per_hour=config.TRANSLATIONS_PER_HOUR,
|
||||
max_concurrent_translations=config.MAX_CONCURRENT_TRANSLATIONS,
|
||||
)
|
||||
rate_limit_manager = RateLimitManager(rate_limit_config)
|
||||
|
||||
cleanup_manager = create_cleanup_manager(config)
|
||||
memory_monitor = MemoryMonitor(
|
||||
max_memory_percent=float(os.getenv("MAX_MEMORY_PERCENT", "80"))
|
||||
)
|
||||
memory_monitor = MemoryMonitor(max_memory_percent=config.MAX_MEMORY_PERCENT)
|
||||
health_checker = HealthChecker(cleanup_manager, memory_monitor)
|
||||
|
||||
|
||||
@@ -350,21 +362,36 @@ app.add_middleware(ErrorHandlingMiddleware)
|
||||
app.add_middleware(RequestLoggingMiddleware, log_body=False)
|
||||
app.add_middleware(
|
||||
SecurityHeadersMiddleware,
|
||||
config={"enable_hsts": os.getenv("ENABLE_HSTS", "false").lower() == "true"},
|
||||
config={"enable_hsts": config.ENABLE_HSTS},
|
||||
)
|
||||
app.add_middleware(RateLimitMiddleware, rate_limit_manager=rate_limit_manager)
|
||||
|
||||
_cors_env = os.getenv("CORS_ORIGINS", "")
|
||||
# Local dev frontends often run on 3000, 3001, etc.; .env may list only one port and break the other.
|
||||
_CORS_EXTRA_DEV_ORIGINS = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3001",
|
||||
]
|
||||
|
||||
_cors_env = config.CORS_ORIGINS_RAW
|
||||
if _cors_env == "*" or not _cors_env:
|
||||
logger.warning(
|
||||
"CORS_ORIGINS not properly configured. Using permissive settings for development only!"
|
||||
)
|
||||
allowed_origins = ["*"]
|
||||
else:
|
||||
allowed_origins = [
|
||||
origin.strip() for origin in _cors_env.split(",") if origin.strip()
|
||||
]
|
||||
logger.info(f"CORS configured for origins: {allowed_origins}")
|
||||
allowed_origins = list(config.CORS_ORIGINS)
|
||||
if config.ENV != "production":
|
||||
for _o in _CORS_EXTRA_DEV_ORIGINS:
|
||||
if _o not in allowed_origins:
|
||||
allowed_origins.append(_o)
|
||||
logger.info(
|
||||
"CORS: non-production — localhost dev ports merged into allowed origins: %s",
|
||||
allowed_origins,
|
||||
)
|
||||
else:
|
||||
logger.info("CORS configured for origins: %s", allowed_origins)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -550,7 +577,7 @@ async def root():
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health_check():
|
||||
"""Health check endpoint with detailed system status (Kubernetes liveness probe)"""
|
||||
REDIS_URL = os.getenv("REDIS_URL", "")
|
||||
from core.redis import get_redis_url, ping_sync
|
||||
|
||||
health_status = await health_checker.check_health()
|
||||
status_code = 200 if health_status.get("status") == "healthy" else 503
|
||||
@@ -559,7 +586,7 @@ async def health_check():
|
||||
try:
|
||||
from database.connection import check_db_connection
|
||||
|
||||
if check_db_connection():
|
||||
if await check_db_connection():
|
||||
db_status = {"status": "healthy"}
|
||||
else:
|
||||
db_status = {"status": "unhealthy"}
|
||||
@@ -567,15 +594,9 @@ async def health_check():
|
||||
db_status = {"status": "error", "error": str(e)}
|
||||
|
||||
redis_status = {"status": "not_configured"}
|
||||
if REDIS_URL:
|
||||
try:
|
||||
import redis
|
||||
|
||||
redis_client = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
redis_client.ping()
|
||||
redis_status = {"status": "healthy"}
|
||||
except Exception as e:
|
||||
redis_status = {"status": "unhealthy", "error": str(e)}
|
||||
if get_redis_url():
|
||||
ok, err = ping_sync()
|
||||
redis_status = {"status": "healthy"} if ok else {"status": "unhealthy", "error": err or "ping failed"}
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
@@ -599,27 +620,24 @@ async def health_check():
|
||||
@app.get("/ready", tags=["Health"])
|
||||
async def readiness_check():
|
||||
"""Kubernetes readiness probe - check if app can serve traffic"""
|
||||
REDIS_URL = os.getenv("REDIS_URL", "")
|
||||
from core.redis import get_redis_url, ping_sync
|
||||
|
||||
issues = []
|
||||
|
||||
try:
|
||||
from database.connection import check_db_connection, DATABASE_URL
|
||||
|
||||
if DATABASE_URL:
|
||||
if not check_db_connection():
|
||||
if not await check_db_connection():
|
||||
issues.append("database_unavailable")
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
issues.append(f"database_error: {str(e)}")
|
||||
|
||||
if REDIS_URL:
|
||||
try:
|
||||
import redis
|
||||
|
||||
redis_client = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
redis_client.ping()
|
||||
except Exception:
|
||||
if get_redis_url():
|
||||
ok, _ = ping_sync()
|
||||
if not ok:
|
||||
issues.append("redis_unavailable")
|
||||
|
||||
if issues:
|
||||
|
||||
Reference in New Issue
Block a user