"""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. """ import uuid from datetime import datetime, timezone from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, 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.api.routers.files import ( _UPLOAD_CHUNK_SIZE, detect_file_type, sanitize_filename, ) from src.backend.config import settings 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) # Radice degli allegati degli esiti: sotto upload_path, mai cancellabile (audit). ATTACHMENT_DIR = "task_results" def _safe_relative_path(path: str) -> bool: """Allegato: relativo, dentro task_results/, senza '..', unita' o backslash.""" if path.startswith("/") or "\\" in path or ":" in path: return False parts = path.split("/") return ".." not in parts and parts[0] == ATTACHMENT_DIR and len(parts) > 1 @router.post("/attachments", status_code=status.HTTP_201_CREATED) async def upload_attachment( file: UploadFile = File(...), _user: User = Depends(require_measurement_tec), ): """Carica l'allegato di un task 'note' (l'operatore non ha accesso a /api/files/upload). Stessi controlli di upload_file: tipo dai magic bytes, limite di dimensione con lettura a blocchi, nome ripulito. Salva sotto task_results///. """ max_bytes = settings.max_upload_size_mb * 1024 * 1024 buffer = bytearray() while True: chunk = await file.read(_UPLOAD_CHUNK_SIZE) if not chunk: break buffer.extend(chunk) if len(buffer) > max_bytes: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB", ) content = bytes(buffer) detected = detect_file_type(content) if detected is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="File type not allowed. Must be a recognised image or PDF.", ) _mime, ext = detected # nome originale ripulito (per mostrarlo); sul disco solo uuid + estensione rilevata name = sanitize_filename(file.filename or "") now = datetime.now(timezone.utc) storage_dir = settings.upload_path / ATTACHMENT_DIR / f"{now:%Y}" / f"{now:%m}" storage_dir.mkdir(parents=True, exist_ok=True) target = storage_dir / f"{uuid.uuid4().hex}{ext}" target.write_bytes(content) return { "file_path": str(target.relative_to(settings.upload_path)).replace("\\", "/"), "name": name, } @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 (deve stare in {ATTACHMENT_DIR}/): {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]