21f8bae5ac
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
193 lines
7.5 KiB
Python
193 lines
7.5 KiB
Python
"""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/<AAAA>/<MM>/.
|
|
"""
|
|
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]
|