From 521a8abc2d2388c373cbed8dc9f02f644a39240b Mon Sep 17 00:00:00 2001 From: zwitschi Date: Thu, 13 Nov 2025 09:54:09 +0100 Subject: [PATCH] feat: Migrate to Pydantic's @field_validator and implement lifespan handler in FastAPI --- changelog.md | 1 + main.py | 65 +++++++++++++++++++++++----------------------- scripts/init_db.py | 4 +-- 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/changelog.md b/changelog.md index c1f9ff1..41baa84 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,7 @@ ## 2025-11-13 +- Cleared FastAPI and Pydantic deprecation warnings by migrating `scripts/init_db.py` to `@field_validator`, replacing the `main.py` startup hook with a lifespan handler, auditing template response call signatures, confirming HTTP 422 constant usage, and re-running the full pytest suite to ensure a clean warning slate. - Delivered the initial capex planner end-to-end: added scaffolded UI in `templates/scenarios/capex.html`, wired GET/POST handlers through `routes/calculations.py`, implemented calculation logic plus snapshot persistence in `services/calculations.py` and `models/capex_snapshot.py`, updated navigation links, and introduced unit tests in `tests/services/test_calculations_capex.py`. - Updated UI navigation to surface the processing opex planner by adding the sidebar link in `templates/partials/sidebar_nav.html`, wiring a scenario detail action in `templates/scenarios/detail.html`. - Completed manual validation of the Initial Capex Planner UI flows (sidebar entry, scenario deep link, validation errors, successful calculation) with results captured in `manual_tests/initial_capex.md`, documented snapshot verification steps, and noted the optional JSON client check for future follow-up. diff --git a/main.py b/main.py index f5f5c1e..24c2f51 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import logging +from contextlib import asynccontextmanager from typing import Awaitable, Callable from fastapi import FastAPI, Request, Response @@ -22,42 +23,14 @@ from monitoring import router as monitoring_router from services.bootstrap import bootstrap_admin, bootstrap_pricing_settings from scripts.init_db import init_db as init_db_script -app = FastAPI() - -app.add_middleware(AuthSessionMiddleware) -app.add_middleware(MetricsMiddleware) - logger = logging.getLogger(__name__) -@app.middleware("http") -async def json_validation( - request: Request, call_next: Callable[[Request], Awaitable[Response]] -) -> Response: - return await validate_json(request, call_next) - - -@app.get("/health", summary="Container health probe") -async def health() -> dict[str, str]: - return {"status": "ok"} - - -@app.get("/favicon.ico", include_in_schema=False) -async def favicon() -> Response: - static_directory = "static" - favicon_img = "favicon.ico" - return FileResponse(f"{static_directory}/{favicon_img}") - - -@app.on_event("startup") -# TODO: use lifespan events for startup/shutdown tasks -async def ensure_admin_bootstrap() -> None: +async def _bootstrap_startup() -> None: settings = get_settings() admin_settings = settings.admin_bootstrap_settings() pricing_metadata = settings.pricing_metadata() try: - # Ensure DB schema/types/seeds required for bootstrapping exist. - # The initializer is idempotent and safe to run on every startup. try: init_db_script() except Exception: @@ -74,9 +47,6 @@ async def ensure_admin_bootstrap() -> None: admin_result.password_rotated, admin_result.roles_granted, ) - # Avoid accessing ORM-managed attributes that may be detached outside - # of the UnitOfWork/session scope. Attempt a safe extraction and - # fall back to minimal logging if attributes are unavailable. try: seed = pricing_result.seed slug = getattr(seed.settings, "slug", None) if seed and getattr( @@ -102,6 +72,37 @@ async def ensure_admin_bootstrap() -> None: "Failed to bootstrap administrator or pricing settings") +@asynccontextmanager +async def app_lifespan(_: FastAPI): + await _bootstrap_startup() + yield + + +app = FastAPI(lifespan=app_lifespan) + +app.add_middleware(AuthSessionMiddleware) +app.add_middleware(MetricsMiddleware) + + +@app.middleware("http") +async def json_validation( + request: Request, call_next: Callable[[Request], Awaitable[Response]] +) -> Response: + return await validate_json(request, call_next) + + +@app.get("/health", summary="Container health probe") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/favicon.ico", include_in_schema=False) +async def favicon() -> Response: + static_directory = "static" + favicon_img = "favicon.ico" + return FileResponse(f"{static_directory}/{favicon_img}") + + app.include_router(dashboard_router) app.include_router(calculations_router) app.include_router(auth_router) diff --git a/scripts/init_db.py b/scripts/init_db.py index 5da5929..3f48f99 100644 --- a/scripts/init_db.py +++ b/scripts/init_db.py @@ -22,7 +22,7 @@ import os import logging from decimal import Decimal -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field, field_validator from sqlalchemy import create_engine, text from sqlalchemy.engine import Engine from passlib.context import CryptContext @@ -454,7 +454,7 @@ class UserSeed(BaseModel): is_active: bool = True is_superuser: bool = False - @validator("password") + @field_validator("password") def password_min_len(cls, v: str) -> str: if not v or len(v) < 8: raise ValueError("password must be at least 8 characters")