Files
data-entry-app/backend/app/main.py
T

333 lines
11 KiB
Python
Raw Normal View History

2026-04-27 21:53:36 +12:00
import logging
2026-05-10 09:46:07 +12:00
import re
2026-04-25 20:43:37 +12:00
import sys
from contextlib import asynccontextmanager
2026-05-10 09:46:07 +12:00
from importlib.metadata import PackageNotFoundError, version as package_version
2026-04-25 20:43:37 +12:00
from pathlib import Path
2026-04-27 21:53:36 +12:00
from threading import Lock
2026-05-10 09:46:07 +12:00
from typing import Final
2026-04-25 20:43:37 +12:00
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
2026-05-10 09:46:07 +12:00
from fastapi import Request
from fastapi import FastAPI, HTTPException, status
2026-04-25 20:43:37 +12:00
from fastapi.middleware.cors import CORSMiddleware
2026-05-10 09:46:07 +12:00
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import JSONResponse
2026-04-25 20:43:37 +12:00
import uvicorn
2026-05-10 09:46:07 +12:00
from app import models as _models # noqa: F401 - ensure all SQLAlchemy models are registered
from app.api.access import router as access_router
2026-04-25 20:43:37 +12:00
from app.api.auth import router as auth_router
2026-04-25 22:51:36 +12:00
from app.api.client_access import router as client_access_router
from app.api.dashboard import router as dashboard_router
2026-06-03 00:17:12 +12:00
from app.api.editor import router as editor_router
2026-04-29 23:05:27 +12:00
from app.api.mix_calculator import router as mix_calculator_router
2026-04-25 20:43:37 +12:00
from app.api.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_router
2026-06-09 21:28:53 +12:00
from app.api.product_costing import router as product_costing_router
2026-04-25 20:43:37 +12:00
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
2026-05-31 20:19:44 +12:00
from app.api.throughput import router as throughput_router
2026-04-25 20:43:37 +12:00
from app.core.config import settings
2026-05-10 09:46:07 +12:00
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,
)
2026-04-25 20:43:37 +12:00
from app.db.session import Base, engine
2026-05-10 09:46:07 +12:00
from app.db.migrations import MigrationReport, bootstrap_schema, sync_product_visibility, sync_tenant_ids
2026-05-31 20:19:44 +12:00
from app.seed import seed_startup_basics
2026-04-25 20:43:37 +12:00
2026-05-10 09:46:07 +12:00
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)
2026-04-27 21:53:36 +12:00
logger = logging.getLogger("data_entry_app.startup")
_database_ready = False
_database_ready_lock = Lock()
2026-05-10 09:46:07 +12:00
_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
2026-04-27 21:53:36 +12:00
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)
2026-05-31 20:19:44 +12:00
seed_startup_basics()
2026-04-27 21:53:36 +12:00
tenant_sync_report = sync_tenant_ids(engine)
2026-05-10 09:46:07 +12:00
hidden_product_count = sync_product_visibility(engine)
2026-04-27 21:53:36 +12:00
report = MigrationReport(
created_tables=schema_report.created_tables,
added_columns=schema_report.added_columns,
2026-05-10 09:46:07 +12:00
synced_tenant_rows={
**tenant_sync_report,
**({"products_visibility": hidden_product_count} if hidden_product_count else {}),
},
2026-04-27 21:53:36 +12:00
)
logger.info("Database startup checks complete: %s", report.summary())
_database_ready = True
return report
2026-04-25 20:43:37 +12:00
@asynccontextmanager
2026-05-10 09:46:07 +12:00
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")
2026-05-31 20:19:44 +12:00
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")
2026-05-10 09:46:07 +12:00
info("Health probe available at /health", logger_name="data_entry_app.services")
2026-04-25 20:43:37 +12:00
yield
2026-05-10 09:46:07 +12:00
shutdown_summary(
uptime_seconds=launch_time.elapsed_ms / 1000,
requests_served=_requests_served,
host=settings.host,
port=settings.port,
)
2026-04-25 20:43:37 +12:00
2026-05-10 09:46:07 +12:00
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 ["*"])
2026-04-25 20:43:37 +12:00
app.add_middleware(
CORSMiddleware,
2026-04-27 21:53:36 +12:00
allow_origins=list(settings.cors_allow_origins),
2026-05-10 09:46:07 +12:00
allow_origin_regex=settings.cors_allow_origin_regex or None,
2026-04-25 20:43:37 +12:00
allow_credentials=True,
2026-05-10 09:46:07 +12:00
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Requested-With"],
2026-04-25 20:43:37 +12:00
)
app.include_router(auth_router)
app.include_router(access_router)
2026-04-25 22:51:36 +12:00
app.include_router(client_access_router)
app.include_router(dashboard_router)
2026-06-03 00:17:12 +12:00
app.include_router(editor_router)
2026-04-25 20:43:37 +12:00
app.include_router(raw_materials_router)
app.include_router(mixes_router)
2026-04-29 23:05:27 +12:00
app.include_router(mix_calculator_router)
2026-06-09 21:28:53 +12:00
app.include_router(product_costing_router)
2026-04-25 20:43:37 +12:00
app.include_router(products_router)
app.include_router(scenarios_router)
2026-05-31 20:19:44 +12:00
app.include_router(throughput_router)
2026-04-25 20:43:37 +12:00
app.include_router(powerbi_router)
2026-05-10 09:46:07 +12:00
@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"})
2026-04-25 20:43:37 +12:00
@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": {
2026-04-25 22:51:36 +12:00
"client_login": "/api/auth/client/login",
"admin_login": "/api/auth/admin/login",
2026-04-25 20:43:37 +12:00
"raw_materials": "/api/raw-materials",
"mixes": "/api/mixes",
2026-04-29 23:05:27 +12:00
"mix_calculator": "/api/mix-calculator",
2026-04-25 20:43:37 +12:00
"products": "/api/products",
"scenarios": "/api/scenarios",
2026-05-31 20:19:44 +12:00
"operations_throughput": "/api/throughput",
2026-04-25 22:51:36 +12:00
"client_access": "/api/client-access",
2026-04-25 20:43:37 +12:00
"docs": "/docs",
},
}
@app.get("/health")
def healthcheck():
return {"status": "ok"}
if __name__ == "__main__":
2026-04-27 21:53:36 +12:00
report = ensure_database_ready()
2026-05-10 09:46:07 +12:00
success("Database startup checks complete: %s", report.summary(), logger_name="data_entry_app.startup")
2026-04-25 20:43:37 +12:00
uvicorn.run(
app,
2026-05-10 09:46:07 +12:00
host=settings.host,
port=settings.port,
access_log=False,
2026-04-25 20:43:37 +12:00
)