Files
TieMeasureFlow/server/services/recipe_service.py
T
Adriano dd2ebf863a feat: FASE 7 - Polish & Testing (security, i18n, test suite, docs)
Security hardening: CORS lockdown, rate limiting middleware con sliding
window e eviction IP stale, security headers (CSP, HSTS, X-Frame-Options),
session cookie hardening, filename sanitization upload.

i18n completion: internazionalizzati barcode.js e csv-export.js con bridge
window.BARCODE_I18N/CSV_I18N, aggiornati .po IT/EN con 27 nuove stringhe.

Tablet UX: touch target 44px per dispositivi coarse pointer.

Test suite: 101 test totali (76 server + 25 client), copertura completa
di tutti i router API, autenticazione, ruoli, CRUD, SPC, file upload,
security integration. Infrastruttura SQLite async in-memory con fixtures.

Fix critici: MissingGreenlet in recipe_service (selectinload eager),
route ordering tasks.py, auth_service bcrypt diretto, Measurement.id
Integer per SQLite.

Documentazione: API.md (riferimento completo 40+ endpoint),
DEPLOYMENT.md (guida produzione con Docker/Nginx/SSL),
USER_GUIDE.md (manuale utente per ruolo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 17:10:24 +01:00

460 lines
13 KiB
Python

"""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",
)
# Reload recipe with versions + tasks eagerly loaded
result = await db.execute(
select(Recipe)
.where(Recipe.id == recipe.id)
.options(
selectinload(Recipe.versions)
.selectinload(RecipeVersion.tasks)
.selectinload(RecipeTask.subtasks)
)
)
return result.scalar_one()
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