import logging import re import sys from contextlib import asynccontextmanager from importlib.metadata import PackageNotFoundError, version as package_version from pathlib import Path from threading import Lock from typing import Final if __package__ in {None, ""}: sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from fastapi import Request from fastapi import FastAPI, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.trustedhost import TrustedHostMiddleware from fastapi.responses import JSONResponse import uvicorn from app import models as _models # noqa: F401 - ensure all SQLAlchemy models are registered from app.api.access import router as access_router from app.api.auth import router as auth_router from app.api.client_access import router as client_access_router from app.api.dashboard import router as dashboard_router from app.api.mix_calculator import router as mix_calculator_router from app.api.mixes import router as mixes_router from app.api.powerbi import router as powerbi_router from app.api.products import router as products_router from app.api.raw_materials import router as raw_materials_router from app.api.scenarios import router as scenarios_router from app.api.throughput import router as throughput_router from app.core.config import settings from app.core.logging import ( LoggingSettings, RequestTimer, configure_logging, debug, fatal, info, log_request, route_summary, section_heading, shutdown_summary, startup_banner, startup_status, success, ) from app.db.session import Base, engine from app.db.migrations import MigrationReport, bootstrap_schema, sync_product_visibility, sync_tenant_ids from app.seed import seed_startup_basics def _resolve_version() -> str: try: return package_version("data-entry-app-backend") except PackageNotFoundError: return "0.0.0" APP_VERSION: Final[str] = _resolve_version() _logging_settings = LoggingSettings( app_name=settings.app_name, app_env=settings.app_env, host=settings.host, port=settings.port, log_level=settings.log_level, log_verbose=settings.log_verbose, database_url=settings.database_url, version=APP_VERSION, ) configure_logging(_logging_settings) logger = logging.getLogger("data_entry_app.startup") _database_ready = False _database_ready_lock = Lock() _requests_served = 0 def _origin_is_allowed(origin: str | None) -> bool: if not origin: return True if origin in settings.cors_allow_origins: return True if settings.cors_allow_origin_regex: return re.fullmatch(settings.cors_allow_origin_regex, origin) is not None return False def ensure_database_ready() -> MigrationReport: global _database_ready if _database_ready: return MigrationReport() with _database_ready_lock: if _database_ready: return MigrationReport() schema_report = bootstrap_schema(engine, Base.metadata) seed_startup_basics() tenant_sync_report = sync_tenant_ids(engine) hidden_product_count = sync_product_visibility(engine) report = MigrationReport( created_tables=schema_report.created_tables, added_columns=schema_report.added_columns, synced_tenant_rows={ **tenant_sync_report, **({"products_visibility": hidden_product_count} if hidden_product_count else {}), }, ) logger.info("Database startup checks complete: %s", report.summary()) _database_ready = True return report @asynccontextmanager async def lifespan(app: FastAPI): started = startup_status(_logging_settings) launch_time = RequestTimer() startup_banner(started) section_heading("Startup") info("Booting %s", settings.app_name, logger_name="data_entry_app.startup") section_heading("Configuration") success("Configuration loaded") info("CORS origins: %s", ", ".join(settings.cors_allow_origins), logger_name="data_entry_app.config") if settings.cors_allow_origin_regex: debug("CORS regex: %s", settings.cors_allow_origin_regex, logger_name="data_entry_app.config") section_heading("Database") try: report = ensure_database_ready() except Exception: fatal("Database startup failed", exc_info=True, logger_name="data_entry_app.database") raise success("Database connected") if report.has_changes(): info(report.summary(), logger_name="data_entry_app.database") else: debug(report.summary(), logger_name="data_entry_app.database") section_heading("Routes") route_count, route_lines = route_summary(app.routes) success("Routes registered (%s endpoints)", route_count) if settings.log_verbose: for route_line in route_lines: debug(route_line, logger_name="data_entry_app.routes") section_heading("Services") success("HTTP API ready") if settings.docs_enabled: info("Docs available at /docs", logger_name="data_entry_app.services") else: info("Docs disabled in this environment", logger_name="data_entry_app.services") info("Health probe available at /health", logger_name="data_entry_app.services") yield shutdown_summary( uptime_seconds=launch_time.elapsed_ms / 1000, requests_served=_requests_served, host=settings.host, port=settings.port, ) app = FastAPI( title=settings.app_name, version=APP_VERSION, lifespan=lifespan, docs_url="/docs" if settings.docs_enabled else None, redoc_url=None, openapi_url="/openapi.json" if settings.docs_enabled else None, ) app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(settings.trusted_hosts) or ["*"]) app.add_middleware( CORSMiddleware, allow_origins=list(settings.cors_allow_origins), allow_origin_regex=settings.cors_allow_origin_regex or None, allow_credentials=True, allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allow_headers=["Authorization", "Content-Type", "X-Requested-With"], ) app.include_router(auth_router) app.include_router(access_router) app.include_router(client_access_router) app.include_router(dashboard_router) app.include_router(raw_materials_router) app.include_router(mixes_router) app.include_router(mix_calculator_router) app.include_router(products_router) app.include_router(scenarios_router) app.include_router(throughput_router) app.include_router(powerbi_router) @app.middleware("http") async def log_http_requests(request: Request, call_next): global _requests_served timer = RequestTimer() try: response = await call_next(request) except Exception: log_request( method=request.method, path=request.url.path, status_code=500, duration_ms=timer.elapsed_ms, client=request.client.host if request.client else "-", content_length=None, ) raise _requests_served += 1 log_request( method=request.method, path=request.url.path, status_code=response.status_code, duration_ms=timer.elapsed_ms, client=request.client.host if request.client else "-", content_length=response.headers.get("content-length"), ) return response @app.middleware("http") async def enforce_request_limits_and_csrf(request: Request, call_next): content_length = request.headers.get("content-length") if content_length: try: if int(content_length) > settings.request_body_max_bytes: return JSONResponse( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, content={"detail": "Request body is too large"}, ) except ValueError: pass if request.method in {"POST", "PUT", "PATCH", "DELETE"} and request.cookies: origin = request.headers.get("origin") if not _origin_is_allowed(origin): return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, content={"detail": "Origin is not allowed"}, ) response = await call_next(request) response.headers["Content-Security-Policy"] = ( "default-src 'self'; " "img-src 'self' data:; " "style-src 'self' 'unsafe-inline'; " "script-src 'self'; " "font-src 'self' data:; " "connect-src 'self'; " "frame-ancestors 'self'; " "base-uri 'self'; " "form-action 'self'" ) response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "SAMEORIGIN" response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" if settings.app_env.lower() == "production": response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" return response @app.exception_handler(HTTPException) async def http_exception_handler(_: Request, exc: HTTPException): return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) @app.exception_handler(Exception) async def unhandled_exception_handler(_: Request, exc: Exception): fatal("Unhandled server error", exc_info=True, logger_name="data_entry_app.http") return JSONResponse(status_code=500, content={"detail": "Internal server error"}) @app.get("/") def root(): return { "app": settings.app_name, "message": "Use the operator frontend to sign in, manage raw materials, and review downstream mix and product costs.", "workflow": [ "Sign in as an operator", "Update raw material prices or add new materials", "Review mix master recalculations", "Confirm finished product pricing outputs", ], "endpoints": { "client_login": "/api/auth/client/login", "admin_login": "/api/auth/admin/login", "raw_materials": "/api/raw-materials", "mixes": "/api/mixes", "mix_calculator": "/api/mix-calculator", "products": "/api/products", "scenarios": "/api/scenarios", "operations_throughput": "/api/throughput", "client_access": "/api/client-access", "docs": "/docs", }, } @app.get("/health") def healthcheck(): return {"status": "ok"} if __name__ == "__main__": report = ensure_database_ready() success("Database startup checks complete: %s", report.summary(), logger_name="data_entry_app.startup") uvicorn.run( app, host=settings.host, port=settings.port, access_log=False, )