feat: FASE 1 - Backend Core (modelli, auth, API)

Implementazione completa del backend FastAPI:
- Modelli SQLAlchemy: User, Recipe, RecipeVersion, RecipeTask,
  RecipeSubtask, Measurement, AccessLog, SystemSetting, RecipeVersionAudit
- Schemas Pydantic v2 per tutti i CRUD + statistiche SPC
- Middleware: API Key auth (X-API-Key) con role checking + access logging
- Router: auth, users, recipes, tasks, measurements, files, settings
- Services: auth (bcrypt+secrets), recipe (copy-on-write versioning),
  measurement (auto pass/fail con UTL/UWL/LWL/LTL)
- Alembic env.py con import modelli attivi
- Fix architect review: no double-commit, recipe_id subquery filter,
  user_id in access logs, type annotations corrette

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 00:40:50 +01:00
parent 76be6f5ac4
commit d6508e0ae8
28 changed files with 2942 additions and 7 deletions
+22
View File
@@ -0,0 +1,22 @@
"""FastAPI middleware for TieMeasureFlow."""
from middleware.api_key import (
get_current_user,
require_role,
require_admin,
require_maker,
require_measurement_tec,
require_metrologist,
require_admin_user,
)
from middleware.logging import AccessLogMiddleware
__all__ = [
"get_current_user",
"require_role",
"require_admin",
"require_maker",
"require_measurement_tec",
"require_metrologist",
"require_admin_user",
"AccessLogMiddleware",
]
+71
View File
@@ -0,0 +1,71 @@
"""API Key authentication dependency for FastAPI."""
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models.user import User
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> User:
"""Extract API key from header and return the authenticated user.
The API key is sent in the X-API-Key header on every request.
Login endpoint is excluded from this check.
"""
api_key = request.headers.get("X-API-Key")
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key in X-API-Key header",
)
result = await db.execute(
select(User).where(User.api_key == api_key, User.active == True)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or inactive API key",
)
# Store user_id in request state for access logging middleware
request.state.user_id = user.id
return user
def require_role(role: str):
"""Dependency factory that checks if user has a specific role."""
async def check_role(user: User = Depends(get_current_user)) -> User:
if not user.has_role(role) and not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{role}' required",
)
return user
return check_role
def require_admin():
"""Dependency that checks if user is admin."""
async def check_admin(user: User = Depends(get_current_user)) -> User:
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
return check_admin
# Pre-built role dependencies for convenience
require_maker = require_role("Maker")
require_measurement_tec = require_role("MeasurementTec")
require_metrologist = require_role("Metrologist")
require_admin_user = require_admin()
+54
View File
@@ -0,0 +1,54 @@
"""Access logging middleware for FastAPI."""
import time
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from sqlalchemy import insert
from database import async_session_factory
from models.access_log import AccessLog
class AccessLogMiddleware(BaseHTTPMiddleware):
"""Middleware that logs every API request to the access_logs table."""
# Paths to exclude from logging (health checks, static files)
EXCLUDED_PATHS = {"/api/health", "/docs", "/openapi.json", "/redoc"}
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Skip excluded paths
if request.url.path in self.EXCLUDED_PATHS:
return await call_next(request)
start_time = time.time()
response = await call_next(request)
duration_ms = (time.time() - start_time) * 1000
# Extract user info from request state (set by auth middleware)
user_id = getattr(request.state, "user_id", None)
# Log asynchronously (don't block response)
try:
async with async_session_factory() as session:
await session.execute(
insert(AccessLog).values(
user_id=user_id,
action=f"{request.method} {request.url.path}",
details={
"method": request.method,
"path": request.url.path,
"query": str(request.query_params),
"status_code": response.status_code,
"duration_ms": round(duration_ms, 2),
},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", "")[:500],
)
)
await session.commit()
except Exception:
# Don't fail the request if logging fails
pass
return response