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
+97
View File
@@ -0,0 +1,97 @@
"""Authentication service - password hashing, API key management."""
import secrets
from datetime import datetime
from passlib.context import CryptContext
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return pwd_context.verify(plain_password, hashed_password)
def generate_api_key() -> str:
"""Generate a secure 64-character API key."""
return secrets.token_urlsafe(48) # 64 chars base64
async def authenticate_user(
db: AsyncSession, username: str, password: str
) -> User | None:
"""Authenticate user by username and password."""
result = await db.execute(
select(User).where(User.username == username, User.active == True)
)
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash):
return None
return user
async def login_user(db: AsyncSession, user: User) -> str:
"""Generate API key and update last_login for user."""
api_key = generate_api_key()
await db.execute(
update(User)
.where(User.id == user.id)
.values(api_key=api_key, last_login=datetime.utcnow())
)
await db.flush()
return api_key
async def logout_user(db: AsyncSession, user: User) -> None:
"""Invalidate user's API key."""
await db.execute(
update(User).where(User.id == user.id).values(api_key=None)
)
await db.flush()
async def create_user(
db: AsyncSession,
username: str,
password: str,
display_name: str,
email: str | None = None,
roles: list[str] | None = None,
is_admin: bool = False,
language_pref: str = "it",
theme_pref: str = "light",
) -> User:
"""Create a new user with hashed password."""
user = User(
username=username,
password_hash=hash_password(password),
display_name=display_name,
email=email,
roles=roles or [],
is_admin=is_admin,
language_pref=language_pref,
theme_pref=theme_pref,
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
"""Regenerate API key for a user."""
new_key = generate_api_key()
await db.execute(
update(User).where(User.id == user_id).values(api_key=new_key)
)
await db.flush()
return new_key
+77
View File
@@ -0,0 +1,77 @@
"""Measurement service - pass/fail calculation, data storage."""
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.measurement import Measurement
from models.task import RecipeSubtask
def calculate_pass_fail(
value: float, subtask: RecipeSubtask
) -> tuple[str, float | None]:
"""Calculate pass/fail status and deviation based on tolerances.
Returns (status, deviation) where status is 'pass', 'warning', or 'fail'.
Logic:
- If value is outside UTL/LTL -> 'fail'
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
- Otherwise -> 'pass'
"""
deviation = None
if subtask.nominal is not None:
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
# Check fail (outside tolerance limits)
if subtask.utl is not None and value > float(subtask.utl):
return "fail", deviation
if subtask.ltl is not None and value < float(subtask.ltl):
return "fail", deviation
# Check warning (outside warning limits)
if subtask.uwl is not None and value > float(subtask.uwl):
return "warning", deviation
if subtask.lwl is not None and value < float(subtask.lwl):
return "warning", deviation
return "pass", deviation
async def save_measurement(
db: AsyncSession,
subtask_id: int,
version_id: int,
measured_by: int,
value: float,
lot_number: str | None = None,
serial_number: str | None = None,
input_method: str = "manual",
) -> Measurement:
"""Save a single measurement with auto-calculated pass/fail."""
# Get subtask for tolerance values
result = await db.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
)
subtask = result.scalar_one_or_none()
if subtask is None:
raise ValueError(f"Subtask {subtask_id} not found")
pass_fail, deviation = calculate_pass_fail(value, subtask)
measurement = Measurement(
subtask_id=subtask_id,
version_id=version_id,
measured_by=measured_by,
value=value,
pass_fail=pass_fail,
deviation=deviation,
lot_number=lot_number,
serial_number=serial_number,
input_method=input_method,
)
db.add(measurement)
await db.flush()
await db.refresh(measurement)
return measurement
+451
View File
@@ -0,0 +1,451 @@
"""Recipe service - business logic for copy-on-write versioning."""
import math
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.measurement import Measurement
from models.recipe import Recipe, RecipeVersion
from models.setting import RecipeVersionAudit
from models.task import RecipeSubtask, RecipeTask
from models.user import User
from schemas.recipe import RecipeCreate, RecipeUpdate
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
"""Return a recipe or raise 404."""
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
recipe = result.scalar_one_or_none()
if recipe is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return recipe
async def _get_current_version(
db: AsyncSession, recipe_id: int
) -> Optional[RecipeVersion]:
"""Return the current version for a recipe (with tasks/subtasks loaded)."""
result = await db.execute(
select(RecipeVersion)
.where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
.options(
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
)
)
return result.scalar_one_or_none()
async def _copy_tasks_to_version(
db: AsyncSession,
source_version: RecipeVersion,
target_version: RecipeVersion,
) -> None:
"""Deep-copy all tasks and subtasks from *source* to *target* version."""
for task in source_version.tasks:
new_task = RecipeTask(
version_id=target_version.id,
order_index=task.order_index,
title=task.title,
directive=task.directive,
description=task.description,
file_path=task.file_path,
file_type=task.file_type,
annotations_json=task.annotations_json,
)
db.add(new_task)
await db.flush() # get new_task.id
for sub in task.subtasks:
new_sub = RecipeSubtask(
task_id=new_task.id,
marker_number=sub.marker_number,
description=sub.description,
measurement_type=sub.measurement_type,
nominal=sub.nominal,
utl=sub.utl,
uwl=sub.uwl,
lwl=sub.lwl,
ltl=sub.ltl,
unit=sub.unit,
)
db.add(new_sub)
await db.flush()
async def _write_audit(
db: AsyncSession,
recipe_id: int,
old_version_id: Optional[int],
new_version_id: int,
changed_by: int,
change_type: str,
change_reason: Optional[str] = None,
) -> None:
"""Persist an audit record for a version change."""
audit = RecipeVersionAudit(
recipe_id=recipe_id,
old_version_id=old_version_id,
new_version_id=new_version_id,
changed_by=changed_by,
change_type=change_type,
change_reason=change_reason,
)
db.add(audit)
await db.flush()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def create_recipe(
db: AsyncSession,
data: RecipeCreate,
user: User,
) -> Recipe:
"""Create a recipe together with its first version (v1).
Returns the newly created Recipe with the current version loaded.
"""
# Check code uniqueness
existing = await db.execute(
select(Recipe).where(Recipe.code == data.code)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Recipe code '{data.code}' already exists",
)
recipe = Recipe(
code=data.code,
name=data.name,
description=data.description,
created_by=user.id,
)
db.add(recipe)
await db.flush()
version = RecipeVersion(
recipe_id=recipe.id,
version_number=1,
is_current=True,
created_by=user.id,
change_notes="Initial version",
)
db.add(version)
await db.flush()
await _write_audit(
db,
recipe_id=recipe.id,
old_version_id=None,
new_version_id=version.id,
changed_by=user.id,
change_type="CREATE",
change_reason="Recipe created",
)
# Refresh so relationships are loaded
await db.refresh(recipe, attribute_names=["versions"])
return recipe
async def create_new_version(
db: AsyncSession,
recipe_id: int,
data: RecipeUpdate,
user: User,
) -> RecipeVersion:
"""Copy-on-write: create a new version by cloning current tasks/subtasks.
1. Fetch current version (with tasks + subtasks)
2. Create a new RecipeVersion with incremented version_number
3. Deep-copy all tasks + subtasks into the new version
4. Apply updates from *data* to the recipe header
5. Flip is_current: old -> False, new -> True
6. Write audit trail
"""
recipe = await _get_recipe_or_404(db, recipe_id)
if not recipe.active:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot update an inactive recipe",
)
current = await _get_current_version(db, recipe_id)
if current is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No current version found for this recipe",
)
old_version_id = current.id
new_version_number = current.version_number + 1
# Mark old version as non-current
await db.execute(
update(RecipeVersion)
.where(RecipeVersion.id == current.id)
.values(is_current=False)
)
# Create new version
new_version = RecipeVersion(
recipe_id=recipe_id,
version_number=new_version_number,
is_current=True,
created_by=user.id,
change_notes=data.change_notes,
)
db.add(new_version)
await db.flush()
# Deep-copy tasks + subtasks
await _copy_tasks_to_version(db, source_version=current, target_version=new_version)
# Apply header updates
update_fields: dict = {}
if data.name is not None:
update_fields["name"] = data.name
if data.description is not None:
update_fields["description"] = data.description
if update_fields:
await db.execute(
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
)
# Audit
await _write_audit(
db,
recipe_id=recipe_id,
old_version_id=old_version_id,
new_version_id=new_version.id,
changed_by=user.id,
change_type="UPDATE",
change_reason=data.change_notes,
)
await db.flush()
# Reload the version with tasks for the response
result = await db.execute(
select(RecipeVersion)
.where(RecipeVersion.id == new_version.id)
.options(
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
)
)
return result.scalar_one()
async def get_current_version(
db: AsyncSession, recipe_id: int
) -> RecipeVersion:
"""Return the current version with tasks and subtasks.
Raises 404 if the recipe or its current version cannot be found.
"""
await _get_recipe_or_404(db, recipe_id)
version = await _get_current_version(db, recipe_id)
if version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No current version found for this recipe",
)
return version
async def get_measurement_count(
db: AsyncSession, recipe_id: int, version_number: int
) -> int:
"""Return the number of measurements recorded against a specific version."""
# Resolve version_id from recipe_id + version_number
result = await db.execute(
select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.version_number == version_number,
)
)
version_id = result.scalar_one_or_none()
if version_id is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Version {version_number} not found for recipe {recipe_id}",
)
count_result = await db.execute(
select(func.count()).select_from(Measurement).where(
Measurement.version_id == version_id
)
)
return count_result.scalar_one()
async def list_recipes(
db: AsyncSession,
page: int = 1,
per_page: int = 20,
search: Optional[str] = None,
) -> dict:
"""Return a paginated list of active recipes.
Each recipe includes its current version (with tasks).
"""
base_filter = [Recipe.active == True] # noqa: E712
if search:
like_pattern = f"%{search}%"
base_filter.append(
(Recipe.name.ilike(like_pattern)) | (Recipe.code.ilike(like_pattern))
)
# Total count
count_stmt = select(func.count()).select_from(Recipe).where(*base_filter)
total = (await db.execute(count_stmt)).scalar_one()
pages = max(1, math.ceil(total / per_page))
offset = (page - 1) * per_page
# Fetch recipes
stmt = (
select(Recipe)
.where(*base_filter)
.order_by(Recipe.name.asc())
.offset(offset)
.limit(per_page)
)
result = await db.execute(stmt)
recipes = result.scalars().all()
return {
"items": recipes,
"total": total,
"page": page,
"per_page": per_page,
"pages": pages,
}
async def get_recipe_detail(db: AsyncSession, recipe_id: int) -> Recipe:
"""Return a single recipe with its current version + tasks loaded."""
result = await db.execute(
select(Recipe)
.where(Recipe.id == recipe_id)
.options(
selectinload(Recipe.versions)
.selectinload(RecipeVersion.tasks)
.selectinload(RecipeTask.subtasks)
)
)
recipe = result.scalar_one_or_none()
if recipe is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found"
)
return recipe
async def get_recipe_by_code(db: AsyncSession, code: str) -> Recipe:
"""Return a recipe by its barcode/code, with current version loaded."""
result = await db.execute(
select(Recipe)
.where(Recipe.code == code, Recipe.active == True) # noqa: E712
.options(
selectinload(Recipe.versions)
.selectinload(RecipeVersion.tasks)
.selectinload(RecipeTask.subtasks)
)
)
recipe = result.scalar_one_or_none()
if recipe is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No active recipe found with code '{code}'",
)
return recipe
async def deactivate_recipe(
db: AsyncSession, recipe_id: int, user: User
) -> Recipe:
"""Soft-delete a recipe by setting active=False."""
recipe = await _get_recipe_or_404(db, recipe_id)
if not recipe.active:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Recipe is already inactive",
)
await db.execute(
update(Recipe).where(Recipe.id == recipe_id).values(active=False)
)
# Audit
current = await _get_current_version(db, recipe_id)
if current is not None:
await _write_audit(
db,
recipe_id=recipe_id,
old_version_id=current.id,
new_version_id=current.id,
changed_by=user.id,
change_type="RETIRE",
change_reason="Recipe deactivated",
)
await db.flush()
await db.refresh(recipe)
return recipe
async def list_versions(
db: AsyncSession, recipe_id: int
) -> list[RecipeVersion]:
"""Return all versions of a recipe, ordered by version_number desc."""
await _get_recipe_or_404(db, recipe_id)
result = await db.execute(
select(RecipeVersion)
.where(RecipeVersion.recipe_id == recipe_id)
.order_by(RecipeVersion.version_number.desc())
.options(
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
)
)
return list(result.scalars().all())
async def get_version_detail(
db: AsyncSession, recipe_id: int, version_number: int
) -> RecipeVersion:
"""Return a specific version of a recipe with tasks loaded."""
await _get_recipe_or_404(db, recipe_id)
result = await db.execute(
select(RecipeVersion)
.where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.version_number == version_number,
)
.options(
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
)
)
version = result.scalar_one_or_none()
if version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Version {version_number} not found for recipe {recipe_id}",
)
return version