Updates
This commit is contained in:
+210
-13
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user