333 lines
11 KiB
Python
333 lines
11 KiB
Python
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.editor import router as editor_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.product_costing import router as product_costing_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(editor_router)
|
|
app.include_router(raw_materials_router)
|
|
app.include_router(mixes_router)
|
|
app.include_router(mix_calculator_router)
|
|
app.include_router(product_costing_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,
|
|
)
|