Task: in_loop, at_close, fields, tipo check, task_results, close_task_ids, rate limit login-pin (#53 #54 #55 #59)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 12:42:13 +02:00
parent e07c4d4eee
commit df1727be73
14 changed files with 493 additions and 5 deletions
+3 -1
View File
@@ -35,6 +35,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
"""
LOGIN_PATH = "/api/auth/login"
# #59: il login con PIN e' lo stesso attacco (4 cifre: ancora piu' facile da indovinare)
LOGIN_PIN_PATH = "/api/auth/login-pin"
WINDOW_SECONDS = 60
# Paths that carry a supervisor's (capoturno) credentials in the request
@@ -97,7 +99,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
return True
if method != "POST":
return False
if path == cls.LOGIN_PATH:
if path in (cls.LOGIN_PATH, cls.LOGIN_PIN_PATH):
return True
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
+3
View File
@@ -27,6 +27,7 @@ router = APIRouter(prefix="/api/production-runs", tags=["production"])
_DERIVED = {
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
"close_task_ids",
}
@@ -53,6 +54,8 @@ async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunRes
measurement_task_ids=await production_service.measurement_task_ids(
db, run.version_id,
),
# #53: task da compilare alla fine produzione
close_task_ids=await production_service.close_task_ids(db, run.version_id),
)
+132
View File
@@ -0,0 +1,132 @@
"""Task results router: l'esito di un task 'check' (OK/NOK) o 'note' (#54 #55).
Un 'check' registra ok o nok (nok vuole la nota); un 'note' registra 'done' con i
valori dei campi extra e gli allegati. I task di misura non passano di qui: le
loro letture sono Measurement.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.middleware.api_key import require_measurement_tec
from src.backend.database import get_db
from src.backend.models.api.task_result import TaskResultCreate, TaskResultResponse
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.task_result import TaskResult
from src.backend.models.orm.user import User
router = APIRouter(prefix="/api/task-results", tags=["task-results"])
def _unprocessable(detail: str) -> HTTPException:
# 422 con detail testuale: l'api_client del frontend legge solo `detail`
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
def _safe_relative_path(path: str) -> bool:
"""Percorso di upload: relativo, senza '..', senza unita' o backslash."""
if path.startswith("/") or "\\" in path or ":" in path:
return False
return ".." not in path.split("/")
@router.post("", response_model=TaskResultResponse, status_code=status.HTTP_201_CREATED)
async def create_task_result(
data: TaskResultCreate,
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Registra l'esito di un task 'check' o 'note'."""
task = (await db.execute(
select(RecipeTask).where(RecipeTask.id == data.task_id)
)).scalar_one_or_none()
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
note = data.note.strip() if data.note else None
note = note or None
values: Optional[dict] = None
attachments: Optional[list] = None
if task.task_type == "check":
if data.outcome not in ("ok", "nok"):
raise _unprocessable("Un controllo si registra come ok o nok")
if data.outcome == "nok" and not note:
raise _unprocessable("Per un NOK serve la nota")
elif task.task_type == "note":
if data.outcome != "done":
raise _unprocessable("Una nota si registra come done")
fields = {f["key"]: f for f in (task.fields or [])}
values = {}
for key, value in (data.values or {}).items():
if key not in fields:
raise _unprocessable(f"Campo '{key}' non previsto dal task")
if fields[key]["type"] == "number" and value.strip() != "":
try:
float(value.replace(",", "."))
except ValueError:
raise _unprocessable(f"Il campo '{fields[key]['label']}' deve essere un numero")
values[key] = value
attachments = []
for att in data.attachments or []:
if not _safe_relative_path(att.path):
raise _unprocessable(f"Percorso allegato non valido: {att.path}")
if att.key is not None and att.key not in fields:
raise _unprocessable(f"Campo allegato '{att.key}' non previsto dal task")
attachments.append(att.model_dump())
values = values or None
attachments = attachments or None
else:
raise _unprocessable(
f"I task di tipo '{task.task_type}' non registrano un esito"
)
if data.production_run_id is not None:
run = (await db.execute(
select(ProductionRun).where(ProductionRun.id == data.production_run_id)
)).scalar_one_or_none()
if run is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found"
)
if run.status == "closed":
raise _unprocessable("La produzione e' gia' chiusa")
if run.version_id != task.version_id:
raise _unprocessable("Il task non appartiene alla versione di questa produzione")
result = TaskResult(
task_id=task.id,
version_id=task.version_id, # presa dal task, non dal client
production_run_id=data.production_run_id,
user_id=user.id,
outcome=data.outcome,
note=note,
values=values,
attachments=attachments,
)
db.add(result)
await db.flush()
await db.refresh(result) # created_at e' un server_default
return TaskResultResponse.model_validate(result)
@router.get("", response_model=list[TaskResultResponse])
async def list_task_results(
production_run_id: Optional[int] = Query(None, gt=0),
task_id: Optional[int] = Query(None, gt=0),
_user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Esiti registrati, filtrabili per produzione e/o task; il piu' recente prima."""
stmt = select(TaskResult)
if production_run_id is not None:
stmt = stmt.where(TaskResult.production_run_id == production_run_id)
if task_id is not None:
stmt = stmt.where(TaskResult.task_id == task_id)
rows = (await db.execute(
stmt.order_by(TaskResult.created_at.desc(), TaskResult.id.desc())
)).scalars().all()
return [TaskResultResponse.model_validate(r) for r in rows]
+34
View File
@@ -17,6 +17,9 @@ from src.backend.models.api.task import (
TaskReorderRequest,
TaskResponse,
TaskUpdate,
check_task_loop_options,
default_in_loop,
normalize_note_fields,
validate_tolerance_order,
)
from src.backend.models.orm.vision import VisionReferenceImage
@@ -202,6 +205,13 @@ async def create_task(
This creates a NEW version via copy-on-write, then appends the task to
the new version. Requires Maker role.
"""
# #53 #55: validazione di in_loop/at_close/fields prima di toccare il db
try:
fields = normalize_note_fields(data.fields)
check_task_loop_options(data.task_type, data.at_close, fields)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
# Ensure recipe exists and is active
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
recipe = result.scalar_one_or_none()
@@ -240,6 +250,10 @@ async def create_task(
file_type=data.file_type,
annotations_json=data.annotations_json,
vision_json=data.vision_json,
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
at_close=data.at_close,
fields=fields,
)
db.add(new_task)
await db.flush()
@@ -341,6 +355,26 @@ async def update_task(
if not update_data:
return TaskResponse.model_validate(task)
# #53 #55: i tre campi nuovi si validano sulla combinazione finale (valori
# nuovi, o quelli gia' sul task per cio' che il client non manda)
if "fields" in update_data:
try:
update_data["fields"] = normalize_note_fields(update_data["fields"])
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
for flag in ("in_loop", "at_close"):
if flag in update_data and update_data[flag] is None:
del update_data[flag] # i booleani non sono nullable: null = non toccare
final_type = update_data.get("task_type") or task.task_type
try:
check_task_loop_options(
final_type,
update_data.get("at_close", task.at_close),
update_data.get("fields", task.fields),
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
task = await _task_for_write(db, task, user)
for field, value in update_data.items():