This commit is contained in:
2026-05-10 09:46:07 +12:00
parent cfc193b713
commit 2f2466ecac
81 changed files with 2571 additions and 413 deletions
+210 -13
View File
@@ -1,17 +1,23 @@
import logging
import os
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 FastAPI
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
@@ -23,13 +29,64 @@ 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.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_tenant_ids
from app.db.migrations import MigrationReport, bootstrap_schema, sync_product_visibility, sync_tenant_ids
from app.seed import seed_if_empty
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:
@@ -45,11 +102,15 @@ def ensure_database_ready() -> MigrationReport:
schema_report = bootstrap_schema(engine, Base.metadata)
seed_if_empty()
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,
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
@@ -57,20 +118,72 @@ def ensure_database_ready() -> MigrationReport:
@asynccontextmanager
async def lifespan(_: FastAPI):
ensure_database_ready()
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")
info("Docs available at /docs", 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, lifespan=lifespan)
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,
allow_origin_regex=settings.cors_allow_origin_regex or None,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Requested-With"],
)
app.include_router(auth_router)
@@ -85,6 +198,89 @@ app.include_router(scenarios_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 {
@@ -117,9 +313,10 @@ def healthcheck():
if __name__ == "__main__":
report = ensure_database_ready()
print(f"Database startup checks complete: {report.summary()}")
success("Database startup checks complete: %s", report.summary(), logger_name="data_entry_app.startup")
uvicorn.run(
app,
host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "8000")),
host=settings.host,
port=settings.port,
access_log=False,
)