feat: Migrate to Pydantic's @field_validator and implement lifespan handler in FastAPI
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 2025-11-13
|
## 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`.
|
- 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`.
|
- 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.
|
- 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.
|
||||||
|
|||||||
65
main.py
65
main.py
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response
|
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 services.bootstrap import bootstrap_admin, bootstrap_pricing_settings
|
||||||
from scripts.init_db import init_db as init_db_script
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
async def _bootstrap_startup() -> None:
|
||||||
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:
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
admin_settings = settings.admin_bootstrap_settings()
|
admin_settings = settings.admin_bootstrap_settings()
|
||||||
pricing_metadata = settings.pricing_metadata()
|
pricing_metadata = settings.pricing_metadata()
|
||||||
try:
|
try:
|
||||||
# Ensure DB schema/types/seeds required for bootstrapping exist.
|
|
||||||
# The initializer is idempotent and safe to run on every startup.
|
|
||||||
try:
|
try:
|
||||||
init_db_script()
|
init_db_script()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -74,9 +47,6 @@ async def ensure_admin_bootstrap() -> None:
|
|||||||
admin_result.password_rotated,
|
admin_result.password_rotated,
|
||||||
admin_result.roles_granted,
|
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:
|
try:
|
||||||
seed = pricing_result.seed
|
seed = pricing_result.seed
|
||||||
slug = getattr(seed.settings, "slug", None) if seed and getattr(
|
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")
|
"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(dashboard_router)
|
||||||
app.include_router(calculations_router)
|
app.include_router(calculations_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
from decimal import Decimal
|
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 import create_engine, text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
@@ -454,7 +454,7 @@ class UserSeed(BaseModel):
|
|||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
is_superuser: bool = False
|
is_superuser: bool = False
|
||||||
|
|
||||||
@validator("password")
|
@field_validator("password")
|
||||||
def password_min_len(cls, v: str) -> str:
|
def password_min_len(cls, v: str) -> str:
|
||||||
if not v or len(v) < 8:
|
if not v or len(v) < 8:
|
||||||
raise ValueError("password must be at least 8 characters")
|
raise ValueError("password must be at least 8 characters")
|
||||||
|
|||||||
Reference in New Issue
Block a user