diff --git a/src/backend/api/middleware/rate_limit.py b/src/backend/api/middleware/rate_limit.py index 22c20f0..77b32d3 100644 --- a/src/backend/api/middleware/rate_limit.py +++ b/src/backend/api/middleware/rate_limit.py @@ -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) diff --git a/src/backend/api/routers/files.py b/src/backend/api/routers/files.py index e6cc22e..72898ef 100644 --- a/src/backend/api/routers/files.py +++ b/src/backend/api/routers/files.py @@ -61,7 +61,8 @@ def detect_file_type(content: bytes) -> tuple[str, str] | None: # Top-level folders nothing may DELETE through this router: statistics/reports are # audit records, the logo is only ever replaced via POST /api/settings/logo. -_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/") +# task_results/: allegati degli esiti dei task (#55), record di audit. +_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/", "task_results/") # GET under these needs a role that is meant to see production data, not just # any authenticated user (a Maker or MeasurementTec has no business reading diff --git a/src/backend/api/routers/production.py b/src/backend/api/routers/production.py index f21c018..7a5eb7e 100644 --- a/src/backend/api/routers/production.py +++ b/src/backend/api/routers/production.py @@ -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), ) diff --git a/src/backend/api/routers/task_results.py b/src/backend/api/routers/task_results.py new file mode 100644 index 0000000..e6881b4 --- /dev/null +++ b/src/backend/api/routers/task_results.py @@ -0,0 +1,192 @@ +"""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] diff --git a/src/backend/api/routers/tasks.py b/src/backend/api/routers/tasks.py index 037a436..8a23988 100644 --- a/src/backend/api/routers/tasks.py +++ b/src/backend/api/routers/tasks.py @@ -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(): diff --git a/src/backend/main.py b/src/backend/main.py index 69cb309..7caeb44 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -23,6 +23,7 @@ from src.backend.api.routers.setup import router as setup_router from src.backend.api.routers.stations import router as stations_router from src.backend.api.routers.production import router as production_router from src.backend.api.routers.vision import router as vision_router +from src.backend.api.routers.task_results import router as task_results_router @asynccontextmanager @@ -77,6 +78,7 @@ app.include_router(setup_router) app.include_router(stations_router) app.include_router(production_router) app.include_router(vision_router) +app.include_router(task_results_router) @app.get("/api/health") diff --git a/src/backend/migrations/versions/018_task_loop_results.py b/src/backend/migrations/versions/018_task_loop_results.py new file mode 100644 index 0000000..6077271 --- /dev/null +++ b/src/backend/migrations/versions/018_task_loop_results.py @@ -0,0 +1,108 @@ +"""task: in_loop / at_close / fields, tipo 'check', tabella task_results (#53 #54 #55) + +in_loop: il task fa parte del ciclo di produzione (prima: implicito dal tipo). +at_close: il task 'note' si compila alla fine produzione. +fields: campi extra del task 'note' (JSON). +task_results: esito registrato da un task 'check' (OK/NOK) o 'note' (done). + +Revision ID: 018_task_loop_results +Revises: 017_user_pin +Create Date: 2026-10-07 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '018_task_loop_results' +down_revision: Union[str, None] = '017_user_pin' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +OLD_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure') +NEW_TYPES = OLD_TYPES + ('check',) +# stessi tipi di MEASURING_TASK_TYPES: fino a oggi erano l'unico ciclo possibile +LOOP_TYPES = ('measure', 'camera_measure', 'dxf_compare') + + +def upgrade() -> None: + with op.batch_alter_table('recipe_tasks') as batch: + batch.add_column(sa.Column( + 'in_loop', sa.Boolean(), nullable=False, server_default=sa.false())) + batch.add_column(sa.Column( + 'at_close', sa.Boolean(), nullable=False, server_default=sa.false())) + batch.add_column(sa.Column('fields', sa.JSON(), nullable=True)) + # enum allargato come in 011: 'check' = Controllo OK/NOK + batch.alter_column( + 'task_type', + existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'), + type_=sa.Enum(*NEW_TYPES, name='task_type_enum'), + existing_nullable=False, + existing_server_default='note', + ) + + # i task di misura erano gia' nel ciclo: lo restano + recipe_tasks = sa.table( + 'recipe_tasks', sa.column('task_type', sa.String), sa.column('in_loop', sa.Boolean)) + op.execute( + recipe_tasks.update() + .where(recipe_tasks.c.task_type.in_(LOOP_TYPES)) + .values(in_loop=True) + ) + + op.create_table( + 'task_results', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('task_id', sa.Integer(), + sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False), + sa.Column('version_id', sa.Integer(), + sa.ForeignKey('recipe_versions.id'), nullable=False), + sa.Column('production_run_id', sa.Integer(), + sa.ForeignKey('production_runs.id', ondelete='SET NULL'), nullable=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('outcome', sa.Enum('ok', 'nok', 'done', name='task_outcome_enum'), + nullable=False), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('values', sa.JSON(), nullable=True), + sa.Column('attachments', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, + server_default=sa.func.now()), + mysql_engine='InnoDB', + mysql_charset='utf8mb4', + ) + op.create_index('ix_task_results_task_id', 'task_results', ['task_id']) + op.create_index('ix_task_results_version_id', 'task_results', ['version_id']) + op.create_index( + 'ix_task_results_production_run_id', 'task_results', ['production_run_id']) + + +def downgrade() -> None: + # il tipo 'check' non esiste prima di 018: se un task lo usa, tornare indietro + # lo lascerebbe con un valore fuori enum + bind = op.get_bind() + used = bind.execute( + sa.text("SELECT COUNT(*) FROM recipe_tasks WHERE task_type = 'check'") + ).scalar() + if used: + raise RuntimeError( + f"Downgrade 018 impossibile: {used} task usano il tipo 'check'. " + "Cambiare il tipo o eliminare quei task prima di tornare indietro." + ) + + op.drop_index('ix_task_results_production_run_id', table_name='task_results') + op.drop_index('ix_task_results_version_id', table_name='task_results') + op.drop_index('ix_task_results_task_id', table_name='task_results') + op.drop_table('task_results') + + with op.batch_alter_table('recipe_tasks') as batch: + batch.alter_column( + 'task_type', + existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'), + type_=sa.Enum(*OLD_TYPES, name='task_type_enum'), + existing_nullable=False, + existing_server_default='note', + ) + batch.drop_column('fields') + batch.drop_column('at_close') + batch.drop_column('in_loop') diff --git a/src/backend/models/api/production.py b/src/backend/models/api/production.py index eae838a..8a25500 100644 --- a/src/backend/models/api/production.py +++ b/src/backend/models/api/production.py @@ -55,6 +55,8 @@ class ProductionRunResponse(BaseModel): # needs them to bring the operator back to the measurement when the interval # expires, whichever screen they are on, and to know which task closes a cycle. measurement_task_ids: list[int] = Field(default_factory=list) + # #53: task 'note' con at_close, da compilare prima della modale del capoturno. + close_task_ids: list[int] = Field(default_factory=list) class ProductionRunWithEventsResponse(ProductionRunResponse): diff --git a/src/backend/models/api/task.py b/src/backend/models/api/task.py index 55902b4..035c903 100644 --- a/src/backend/models/api/task.py +++ b/src/backend/models/api/task.py @@ -1,9 +1,10 @@ """Pydantic schemas for RecipeTask and RecipeSubtask operations.""" +import re from typing import Any, Literal, Optional from pydantic import BaseModel, ConfigDict, Field, model_validator -from src.backend.models.orm.task import TASK_TYPES +from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES # Kept in step with the ORM enum so the two cannot drift apart. TaskType = Literal[TASK_TYPES] @@ -34,6 +35,76 @@ def validate_tolerance_order( ) +# #55: campi extra di un task 'note' +MAX_NOTE_FIELDS = 20 +_FIELD_KEY_RE = re.compile(r"^[a-z0-9_]{1,40}$") +NOTE_FIELD_TYPES = ("text", "number") + + +def normalize_note_fields(fields: Optional[list]) -> Optional[list[dict]]: + """Valida e normalizza `fields` di un task 'note' (#55); lista vuota -> None. + + Solleva ValueError con un messaggio leggibile (il router lo trasforma in 422). + """ + if not fields: + return None + if len(fields) > MAX_NOTE_FIELDS: + raise ValueError(f"Al massimo {MAX_NOTE_FIELDS} campi per task") + out: list[dict] = [] + seen: set[str] = set() + for i, raw in enumerate(fields, start=1): + if not isinstance(raw, dict): + raise ValueError(f"Campo {i}: formato non valido") + key = raw.get("key") + if not isinstance(key, str) or not _FIELD_KEY_RE.match(key): + raise ValueError( + f"Campo {i}: chiave non valida (solo a-z, 0-9, _, da 1 a 40 caratteri)" + ) + if key in seen: + raise ValueError(f"Campo {i}: chiave '{key}' duplicata") + seen.add(key) + label = raw.get("label") + label = label.strip() if isinstance(label, str) else "" + if not 1 <= len(label) <= 100: + raise ValueError(f"Campo '{key}': etichetta da 1 a 100 caratteri") + ftype = raw.get("type") + if ftype not in NOTE_FIELD_TYPES: + raise ValueError(f"Campo '{key}': tipo deve essere text o number") + default = raw.get("default") + if default is not None: + if not isinstance(default, str): + raise ValueError(f"Campo '{key}': il valore predefinito deve essere testo") + if default == "": + default = None + if default is not None and ftype == "number": + try: + float(default.replace(",", ".")) + except ValueError: + raise ValueError(f"Campo '{key}': il valore predefinito non e' un numero") + attachment = raw.get("attachment", False) + if not isinstance(attachment, bool): + raise ValueError(f"Campo '{key}': attachment deve essere true o false") + out.append({ + "key": key, "label": label, "type": ftype, + "default": default, "attachment": attachment, + }) + return out + + +def check_task_loop_options(task_type: str, at_close: bool, fields: Optional[list]) -> None: + """at_close e fields sono ammessi solo per i task 'note' (#53 #55).""" + if task_type != "note": + if at_close: + raise ValueError("'Alla chiusura' e' ammesso solo per i task di tipo note") + if fields: + raise ValueError("I campi extra sono ammessi solo per i task di tipo note") + + +def default_in_loop(task_type: str) -> bool: + """in_loop quando non dichiarato: misure e check si', gli altri no (#53).""" + return task_type in DEFAULT_LOOP_TASK_TYPES + + class SubtaskCreate(BaseModel): """Schema for creating a subtask.""" marker_number: int = Field(..., ge=1) @@ -98,6 +169,10 @@ class TaskCreate(BaseModel): file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") annotations_json: Optional[dict[str, Any]] = None vision_json: Optional[dict] = None + # #53: assente = default dal tipo (misure e check nel ciclo) + in_loop: Optional[bool] = None + at_close: bool = False + fields: Optional[list[dict[str, Any]]] = None subtasks: list[SubtaskCreate] = [] @@ -111,6 +186,9 @@ class TaskUpdate(BaseModel): file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") annotations_json: Optional[dict[str, Any]] = None vision_json: Optional[dict] = None + in_loop: Optional[bool] = None + at_close: Optional[bool] = None + fields: Optional[list[dict[str, Any]]] = None class TaskResponse(BaseModel): @@ -129,6 +207,9 @@ class TaskResponse(BaseModel): file_type: Optional[str] = None annotations_json: Optional[dict[str, Any]] = None vision_json: Optional[dict] = None + in_loop: bool = False + at_close: bool = False + fields: Optional[list[dict[str, Any]]] = None subtasks: list[SubtaskResponse] = [] diff --git a/src/backend/models/api/task_result.py b/src/backend/models/api/task_result.py new file mode 100644 index 0000000..493e274 --- /dev/null +++ b/src/backend/models/api/task_result.py @@ -0,0 +1,37 @@ +"""Pydantic schemas for task results (#54 check OK/NOK, #55 note con campi).""" +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from src.backend.models.api.tipi import UtcDateTime + + +class TaskResultAttachment(BaseModel): + """Un allegato: `path` e' il percorso restituito da /api/files/upload.""" + key: Optional[str] = Field(None, max_length=40) + path: str = Field(..., min_length=1, max_length=500) + name: str = Field(..., min_length=1, max_length=255) + + +class TaskResultCreate(BaseModel): + task_id: int = Field(..., gt=0) + production_run_id: Optional[int] = Field(None, gt=0) + outcome: Literal["ok", "nok", "done"] + note: Optional[str] = None + values: Optional[dict[str, str]] = None + attachments: Optional[list[TaskResultAttachment]] = Field(None, max_length=50) + + +class TaskResultResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + task_id: int + version_id: int + production_run_id: Optional[int] = None + user_id: int + outcome: str + note: Optional[str] = None + values: Optional[dict[str, str]] = None + attachments: Optional[list[TaskResultAttachment]] = None + created_at: UtcDateTime diff --git a/src/backend/models/orm/__init__.py b/src/backend/models/orm/__init__.py index 1afabdb..f4d0945 100644 --- a/src/backend/models/orm/__init__.py +++ b/src/backend/models/orm/__init__.py @@ -3,6 +3,7 @@ from src.backend.models.orm.user import User from src.backend.models.orm.api_key import ApiKey from src.backend.models.orm.recipe import Recipe, RecipeVersion from src.backend.models.orm.task import RecipeTask, RecipeSubtask +from src.backend.models.orm.task_result import TaskResult from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.access_log import AccessLog from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit @@ -17,6 +18,7 @@ __all__ = [ "RecipeVersion", "RecipeTask", "RecipeSubtask", + "TaskResult", "Measurement", "AccessLog", "SystemSetting", diff --git a/src/backend/models/orm/task.py b/src/backend/models/orm/task.py index 7f29422..ff23946 100644 --- a/src/backend/models/orm/task.py +++ b/src/backend/models/orm/task.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Optional from sqlalchemy import ( - DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, + Boolean, DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -15,12 +15,22 @@ if TYPE_CHECKING: # What a task is, declared by whoever writes the recipe instead of guessed from its # contents. The last two are not built yet; they are in the enum from the start # because widening a MySQL enum later is an ALTER on a live table. -TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure") +# 'check' = Controllo OK/NOK, senza quote (#54). +TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure", "check") # Types the operator screen treats as "there are quotes to take here". # dxf_compare produce quote come camera_measure (#46) MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare") +# Tipi che entrano nel ciclo di produzione se in_loop non e' dichiarato (#53): +# le misure di sempre piu' il controllo OK/NOK. +DEFAULT_LOOP_TASK_TYPES = MEASURING_TASK_TYPES + ("check",) + + +def _default_in_loop(context) -> bool: + """Default alla creazione di in_loop: dipende dal tipo del task (#53).""" + return context.get_current_parameters().get("task_type", "note") in DEFAULT_LOOP_TASK_TYPES + class RecipeTask(Base): __tablename__ = "recipe_tasks" @@ -55,6 +65,17 @@ class RecipeTask(Base): # Cartella immutabile (relativa a upload_path) con recipe.json e i file di # meta della ricetta VisionSuite v2 (#46). vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + # #53: il task fa parte del ciclo di produzione (default dal tipo: misure e + # check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano). + in_loop: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=_default_in_loop, server_default="0" + ) + # #53: task 'note' compilato alla fine produzione, prima della modale del capoturno. + at_close: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="0" + ) + # #55: campi extra del task 'note' ([{key,label,type,default,attachment}]). + fields: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) # Relationships version: Mapped["RecipeVersion"] = relationship(back_populates="tasks") diff --git a/src/backend/models/orm/task_result.py b/src/backend/models/orm/task_result.py new file mode 100644 index 0000000..cd6011a --- /dev/null +++ b/src/backend/models/orm/task_result.py @@ -0,0 +1,46 @@ +"""TaskResult: l'esito registrato da un task 'check' (OK/NOK) o 'note' (#54 #55).""" +from datetime import datetime +from typing import Optional + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from src.backend.database import Base + +TASK_OUTCOMES = ("ok", "nok", "done") + + +class TaskResult(Base): + __tablename__ = "task_results" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + task_id: Mapped[int] = mapped_column( + Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"), + nullable=False, index=True, + ) + # Copiata dal task: la versione in cui l'esito e' stato dato non cambia piu'. + version_id: Mapped[int] = mapped_column( + Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True + ) + production_run_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("production_runs.id", ondelete="SET NULL"), + nullable=True, index=True, + ) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + outcome: Mapped[str] = mapped_column( + Enum(*TASK_OUTCOMES, name="task_outcome_enum"), nullable=False + ) + note: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + # {chiave del campo: valore stringa}; allegati: [{key, path, name}] + values: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + attachments: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + + __table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},) + + def __repr__(self) -> str: + return f"" diff --git a/src/backend/services/production_service.py b/src/backend/services/production_service.py index 512e3bf..d477b82 100644 --- a/src/backend/services/production_service.py +++ b/src/backend/services/production_service.py @@ -145,7 +145,21 @@ async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]: select(RecipeTask.id) .where( RecipeTask.version_id == version_id, - RecipeTask.task_type.in_(MEASURING_TASK_TYPES), + # #53: il ciclo lo dichiara il task (in_loop), non piu' il solo tipo. + RecipeTask.in_loop.is_(True), + ) + .order_by(RecipeTask.order_index, RecipeTask.id) + ) + return list(result.scalars().all()) + + +async def close_task_ids(db: AsyncSession, version_id: int) -> list[int]: + """Ids dei task da compilare alla fine produzione (at_close), in ordine (#53).""" + result = await db.execute( + select(RecipeTask.id) + .where( + RecipeTask.version_id == version_id, + RecipeTask.at_close.is_(True), ) .order_by(RecipeTask.order_index, RecipeTask.id) ) diff --git a/src/backend/services/recipe_service.py b/src/backend/services/recipe_service.py index 7a2c3d5..4ed7afe 100644 --- a/src/backend/services/recipe_service.py +++ b/src/backend/services/recipe_service.py @@ -117,6 +117,10 @@ async def _copy_tasks_to_version( vision_json=task.vision_json, # Cartella immutabile: basta il riferimento (#46). vision_recipe_dir=task.vision_recipe_dir, + # #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione. + in_loop=task.in_loop, + at_close=task.at_close, + fields=task.fields, ) db.add(new_task) await db.flush() # get new_task.id diff --git a/src/backend/tests/test_migrations.py b/src/backend/tests/test_migrations.py index 64e9664..9ba63d4 100644 --- a/src/backend/tests/test_migrations.py +++ b/src/backend/tests/test_migrations.py @@ -168,3 +168,68 @@ class TestUserPinMigration: assert not (new & cols()) command.upgrade(cfg, "head") # e di nuovo su assert new <= cols() + + +_SEED_USER_RECIPE_VERSION = [ + "INSERT INTO users (username, password_hash, display_name, roles, is_admin, active," + " language_pref, theme_pref) VALUES ('u', 'x', 'U', '[]', 1, 1, 'en', 'light')", + "INSERT INTO recipes (code, name, active, created_by) VALUES ('R', 'R', 1, 1)", + "INSERT INTO recipe_versions (recipe_id, version_number, is_current, created_by)" + " VALUES (1, 1, 1, 1)", +] + + +class TestTaskLoopResultsMigration: + """018_task_loop_results: in_loop/at_close/fields, tipo check, task_results (#53 #54 #55).""" + + def test_018_upgrade_and_downgrade(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + + def cols(): + return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")} + + command.upgrade(cfg, "head") + assert {"in_loop", "at_close", "fields"} <= cols() + assert "task_results" in _inspect(db_path).get_table_names() + command.downgrade(cfg, "017_user_pin") + assert not ({"in_loop", "at_close", "fields"} & cols()) + assert "task_results" not in _inspect(db_path).get_table_names() + command.upgrade(cfg, "head") # e di nuovo su + + def test_018_marks_existing_measure_tasks_in_loop(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + command.upgrade(cfg, "017_user_pin") + engine = create_engine(f"sqlite:///{db_path}") + with engine.begin() as conn: + for stmt in _SEED_USER_RECIPE_VERSION: + conn.execute(text(stmt)) + for i, t in enumerate(("measure", "camera_measure", "dxf_compare", "note", "drawing")): + conn.execute(text( + "INSERT INTO recipe_tasks (version_id, order_index, task_type, title)" + f" VALUES (1, {i}, '{t}', '{t}')")) + engine.dispose() + + command.upgrade(cfg, "head") + engine = create_engine(f"sqlite:///{db_path}") + with engine.connect() as conn: + rows = dict(conn.execute(text("SELECT task_type, in_loop FROM recipe_tasks")).all()) + engine.dispose() + assert rows == {"measure": 1, "camera_measure": 1, "dxf_compare": 1, "note": 0, "drawing": 0} + + def test_018_downgrade_refused_when_a_check_task_exists(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + command.upgrade(cfg, "head") + engine = create_engine(f"sqlite:///{db_path}") + with engine.begin() as conn: + for stmt in _SEED_USER_RECIPE_VERSION: + conn.execute(text(stmt)) + conn.execute(text( + "INSERT INTO recipe_tasks (version_id, order_index, task_type, title)" + " VALUES (1, 0, 'check', 'c')")) + engine.dispose() + + with pytest.raises(RuntimeError, match="check"): + command.downgrade(cfg, "017_user_pin") diff --git a/src/backend/tests/test_task_loop_fields.py b/src/backend/tests/test_task_loop_fields.py new file mode 100644 index 0000000..bb8f8ea --- /dev/null +++ b/src/backend/tests/test_task_loop_fields.py @@ -0,0 +1,210 @@ +"""Test di in_loop / at_close / fields e del tipo 'check' sui task (#53 #54 #55), +di close_task_ids nella risposta del run e del bucket di rate limit del login PIN.""" +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from src.backend.api.middleware.rate_limit import RateLimitMiddleware +from src.backend.models.orm.measurement import Measurement +from src.backend.models.orm.recipe import RecipeVersion +from src.backend.models.orm.station import Station +from src.backend.models.orm.task import RecipeSubtask, RecipeTask +from src.backend.tests.conftest import auth_headers, create_test_recipe + +FIELDS = [ + {"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False}, + {"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True}, +] + + +async def _post_task(client, user, recipe_id, **body): + return await client.post( + f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user), json=body, + ) + + +async def test_create_defaults_in_loop_from_type(client: AsyncClient, maker_user, db_session): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L1") + await db_session.commit() + expected = {"note": False, "drawing": False, "measure": True, + "camera_measure": True, "dxf_compare": True, "check": True} + for task_type, in_loop in expected.items(): + resp = await _post_task(client, maker_user, recipe.id, title=task_type, task_type=task_type) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["in_loop"] is in_loop, task_type + assert body["at_close"] is False + assert body["fields"] is None + + +async def test_create_explicit_in_loop_and_note_fields(client: AsyncClient, maker_user, db_session): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L2") + await db_session.commit() + resp = await _post_task( + client, maker_user, recipe.id, title="Nota", task_type="note", + in_loop=True, at_close=True, fields=FIELDS, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["in_loop"] is True and body["at_close"] is True + assert body["fields"] == FIELDS + + +@pytest.mark.parametrize("task_type", ["measure", "check", "drawing"]) +async def test_create_at_close_or_fields_rejected_for_other_types( + client: AsyncClient, maker_user, db_session, task_type, +): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=f"REC-L3{task_type}") + await db_session.commit() + resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, at_close=True) + assert resp.status_code == 422 + assert isinstance(resp.json()["detail"], str) + resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, fields=FIELDS) + assert resp.status_code == 422 + + +@pytest.mark.parametrize("bad", [ + [{"key": "A b", "label": "x", "type": "text"}], # chiave non slug + [{"key": "k", "label": "", "type": "text"}], # etichetta vuota + [{"key": "k", "label": "x", "type": "date"}], # tipo ignoto + [{"key": "k", "label": "x", "type": "text"}, {"key": "k", "label": "y", "type": "text"}], # duplicata + [{"key": "k", "label": "x", "type": "number", "default": "abc"}], # default non numerico + [{"key": f"k{i}", "label": "x", "type": "text"} for i in range(21)], # troppi +]) +async def test_create_invalid_fields_rejected(client: AsyncClient, maker_user, db_session, bad): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L4") + await db_session.commit() + resp = await _post_task(client, maker_user, recipe.id, title="n", task_type="note", fields=bad) + assert resp.status_code == 422 + assert isinstance(resp.json()["detail"], str) + + +async def test_update_fields_null_clears_and_type_rules(client: AsyncClient, maker_user, db_session): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L5") + await db_session.commit() + created = (await _post_task( + client, maker_user, recipe.id, title="Nota", task_type="note", + at_close=True, fields=FIELDS, + )).json() + headers = auth_headers(maker_user) + + # il frontend manda sempre i tre campi: fields null = nessun campo + resp = await client.put( + f"/api/tasks/{created['id']}", headers=headers, + json={"in_loop": True, "at_close": False, "fields": None}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["in_loop"] is True and body["at_close"] is False and body["fields"] is None + + # tipo diverso da note con at_close o fields: 422 + resp = await client.put( + f"/api/tasks/{created['id']}", headers=headers, + json={"task_type": "measure", "at_close": True, "fields": None}, + ) + assert resp.status_code == 422 + resp = await client.put( + f"/api/tasks/{created['id']}", headers=headers, + json={"task_type": "check", "at_close": False, "fields": FIELDS}, + ) + assert resp.status_code == 422 + + # un task che ha gia' at_close non puo' passare a measure senza toglierlo + resp = await client.put( + f"/api/tasks/{created['id']}", headers=headers, + json={"at_close": True, "fields": FIELDS}, + ) + assert resp.status_code == 200 + resp = await client.put( + f"/api/tasks/{created['id']}", headers=headers, json={"task_type": "measure"}, + ) + assert resp.status_code == 422 + + +async def test_copy_on_write_carries_the_three_fields( + client: AsyncClient, maker_user, db_session, +): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L6") + await db_session.commit() + created = (await _post_task( + client, maker_user, recipe.id, title="Nota", task_type="note", + in_loop=True, at_close=True, fields=FIELDS, + )).json() + + # una misura sulla versione corrente forza il copy-on-write alla prossima modifica + version = (await db_session.execute( + select(RecipeVersion).where( + RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712 + ) + )).scalar_one() + subtask_id = (await db_session.execute( + select(RecipeSubtask.id).join(RecipeSubtask.task) + .where(RecipeSubtask.task.has(version_id=version.id)) + )).scalars().first() + db_session.add(Measurement( + subtask_id=subtask_id, version_id=version.id, measured_by=maker_user.id, + value=10.0, pass_fail="pass", deviation=0.0, input_method="manual", + )) + await db_session.commit() + # sessione condivisa col client: la collezione version.tasks va riletta + db_session.expire_all() + + resp = await client.put( + f"/api/tasks/{created['id']}", headers=auth_headers(maker_user), + json={"title": "Nota rinominata"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["version_id"] != version.id # e' la copia nella nuova versione + assert body["in_loop"] is True and body["at_close"] is True + assert body["fields"] == FIELDS + + +async def test_run_response_loop_and_close_task_ids( + client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session, +): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L7") + db_session.add(Station(code="ST-L7", name="S", active=True, created_by=admin_user.id)) + await db_session.commit() + + async def mk(**body): + resp = await _post_task(client, maker_user, recipe.id, **body) + assert resp.status_code == 201, resp.text + return resp.json() + + check = await mk(title="Check", task_type="check") + note_loop = await mk(title="Nota ciclo", task_type="note", in_loop=True) + note_out = await mk(title="Nota fuori", task_type="note") + close_a = await mk(title="Chiusura A", task_type="note", at_close=True) + measure_off = await mk(title="Misura fuori", task_type="measure", in_loop=False) + close_b = await mk(title="Chiusura B", task_type="note", at_close=True) + + first_id = (await db_session.execute( + select(RecipeTask.id).where(RecipeTask.version_id == check["version_id"]) + .order_by(RecipeTask.order_index) + )).scalars().first() + + resp = await client.post( + "/api/production-runs", headers=auth_headers(measurement_tec_user), + json={"station_code": "ST-L7", "recipe_id": recipe.id}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + # ciclo: solo in_loop, in ordine (misura della fixture, check, nota in ciclo) + assert body["measurement_task_ids"] == [first_id, check["id"], note_loop["id"]] + assert note_out["id"] not in body["measurement_task_ids"] + assert measure_off["id"] not in body["measurement_task_ids"] + assert body["close_task_ids"] == [close_a["id"], close_b["id"]] + + +class TestLoginPinRateLimitBucket: + """#59: il login con PIN sta nel bucket 'login' come il login con password.""" + + def test_login_pin_is_login_bucket(self): + assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/auth/login-pin") + + def test_login_pin_get_is_not_login_bucket(self): + assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/login-pin") + + def test_pin_users_list_is_not_login_bucket(self): + assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/pin-users") diff --git a/src/backend/tests/test_task_results.py b/src/backend/tests/test_task_results.py new file mode 100644 index 0000000..1b2952e --- /dev/null +++ b/src/backend/tests/test_task_results.py @@ -0,0 +1,240 @@ +"""Test di POST/GET /api/task-results (#54 check OK/NOK, #55 note con campi).""" +from unittest.mock import patch + +from httpx import AsyncClient +from sqlalchemy import select + +from src.backend.models.orm.production import ProductionRun +from src.backend.models.orm.station import Station +from src.backend.tests.conftest import auth_headers, create_test_recipe + +FIELDS = [ + {"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False}, + {"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True}, +] + + +async def _setup(client, db_session, maker_user, admin_user, code): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=code) + db_session.add(Station(code=f"ST-{code}", name="S", active=True, created_by=admin_user.id)) + await db_session.commit() + h = auth_headers(maker_user) + check = (await client.post( + f"/api/recipes/{recipe.id}/tasks", headers=h, json={"title": "C", "task_type": "check"}, + )).json() + note = (await client.post( + f"/api/recipes/{recipe.id}/tasks", headers=h, + json={"title": "N", "task_type": "note", "fields": FIELDS}, + )).json() + return recipe, check, note + + +async def _open_run(client, user, recipe, code): + resp = await client.post( + "/api/production-runs", headers=auth_headers(user), + json={"station_code": f"ST-{code}", "recipe_id": recipe.id}, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _post(client, user, **body): + return await client.post("/api/task-results", headers=auth_headers(user), json=body) + + +async def test_requires_auth_and_role(client: AsyncClient, maker_user, admin_user, db_session): + _, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR0") + resp = await client.post("/api/task-results", json={"task_id": check["id"], "outcome": "ok"}) + assert resp.status_code == 401 + # un Maker senza ruolo di reparto non registra esiti + resp = await _post(client, maker_user, task_id=check["id"], outcome="ok") + assert resp.status_code == 403 + + +async def test_check_ok_and_nok_rules( + client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session, +): + recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR1") + run = await _open_run(client, measurement_tec_user, recipe, "TR1") + + resp = await _post(client, measurement_tec_user, task_id=check["id"], + production_run_id=run["id"], outcome="ok") + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["outcome"] == "ok" + assert body["version_id"] == check["version_id"] + assert body["user_id"] == measurement_tec_user.id + assert body["production_run_id"] == run["id"] + assert body["created_at"] + + for note in (None, "", " "): + resp = await _post(client, measurement_tec_user, task_id=check["id"], + outcome="nok", note=note) + assert resp.status_code == 422 + assert resp.json()["detail"] == "Per un NOK serve la nota" + + resp = await _post(client, measurement_tec_user, task_id=check["id"], + outcome="nok", note=" graffio ") + assert resp.status_code == 201 + assert resp.json()["note"] == "graffio" + + # un check non si chiude con 'done' + resp = await _post(client, measurement_tec_user, task_id=check["id"], outcome="done") + assert resp.status_code == 422 + + +async def test_note_values_and_attachments( + client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session, +): + recipe, _, note = await _setup(client, db_session, maker_user, admin_user, "TR2") + ok = await _post( + client, measurement_tec_user, task_id=note["id"], outcome="done", + values={"peso": "12,5"}, + attachments=[{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}], + ) + assert ok.status_code == 201, ok.text + assert ok.json()["values"] == {"peso": "12,5"} + assert ok.json()["attachments"] == [{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}] + + # solo done + assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="ok")).status_code == 422 + # chiave fuori dai campi + assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done", + values={"altro": "1"})).status_code == 422 + # numero non convertibile + assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done", + values={"peso": "abc"})).status_code == 422 + # allegati con percorso non sicuro + for bad in ("../x.jpg", "/etc/passwd", "a/../b.jpg", "C:/x.jpg", "a\\b.jpg"): + resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done", + attachments=[{"key": None, "path": bad, "name": "x"}]) + assert resp.status_code == 422, bad + # chiave allegato fuori dai campi + resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done", + attachments=[{"key": "boh", "path": "task_results/a.jpg", "name": "x"}]) + assert resp.status_code == 422 + + +async def test_other_task_types_rejected_and_unknown_task( + client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session, +): + recipe, _, _ = await _setup(client, db_session, maker_user, admin_user, "TR3") + measure = (await client.post( + f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user), + json={"title": "M", "task_type": "measure"}, + )).json() + resp = await _post(client, measurement_tec_user, task_id=measure["id"], outcome="ok") + assert resp.status_code == 422 + resp = await _post(client, measurement_tec_user, task_id=999999, outcome="ok") + assert resp.status_code == 404 + + +async def test_run_must_be_open_and_same_version( + client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session, +): + recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4") + _, other_check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4b") + run = await _open_run(client, measurement_tec_user, recipe, "TR4") + + # task di un'altra versione + resp = await _post(client, measurement_tec_user, task_id=other_check["id"], + production_run_id=run["id"], outcome="ok") + assert resp.status_code == 422 + # run inesistente + resp = await _post(client, measurement_tec_user, task_id=check["id"], + production_run_id=999999, outcome="ok") + assert resp.status_code == 404 + + # run chiuso: non registra piu' + row = (await db_session.execute( + select(ProductionRun).where(ProductionRun.id == run["id"]) + )).scalar_one() + row.status = "closed" + await db_session.commit() + resp = await _post(client, measurement_tec_user, task_id=check["id"], + production_run_id=run["id"], outcome="ok") + assert resp.status_code == 422 + + +async def test_list_filters_and_order( + client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session, +): + recipe, check, note = await _setup(client, db_session, maker_user, admin_user, "TR5") + run = await _open_run(client, measurement_tec_user, recipe, "TR5") + a = (await _post(client, measurement_tec_user, task_id=check["id"], + production_run_id=run["id"], outcome="ok")).json() + b = (await _post(client, measurement_tec_user, task_id=note["id"], + production_run_id=run["id"], outcome="done")).json() + c = (await _post(client, measurement_tec_user, task_id=check["id"], outcome="ok")).json() + h = auth_headers(measurement_tec_user) + + resp = await client.get(f"/api/task-results?production_run_id={run['id']}", headers=h) + assert [r["id"] for r in resp.json()] == [b["id"], a["id"]] # piu' recente prima + resp = await client.get(f"/api/task-results?task_id={check['id']}", headers=h) + assert [r["id"] for r in resp.json()] == [c["id"], a["id"]] + resp = await client.get( + f"/api/task-results?task_id={check['id']}&production_run_id={run['id']}", headers=h) + assert [r["id"] for r in resp.json()] == [a["id"]] + assert (await client.get("/api/task-results")).status_code == 401 + + +# --------------------------------------------------------------------------- +# POST /api/task-results/attachments: l'operatore carica gli allegati (#55) +# --------------------------------------------------------------------------- + +# firma PNG valida (magic bytes) piu' un po' di zeri +PNG = bytes([0x89]) + b"PNG" + bytes([13, 10, 26, 10]) + bytes(32) + + +async def test_attachment_upload_ok_for_measurement_tec( + client: AsyncClient, measurement_tec_user, tmp_path, +): + with patch("src.backend.api.routers.task_results.settings") as mock_settings: + mock_settings.upload_path = tmp_path + mock_settings.max_upload_size_mb = 50 + resp = await client.post( + "/api/task-results/attachments", headers=auth_headers(measurement_tec_user), + files={"file": ("foto bella (1).png", PNG, "image/png")}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["name"] == "fotobella1.png" + assert body["file_path"].startswith("task_results/") + assert body["file_path"].endswith(".png") + assert (tmp_path / body["file_path"]).read_bytes() == PNG + + +async def test_attachment_upload_rejects_wrong_type_size_and_role( + client: AsyncClient, measurement_tec_user, maker_user, tmp_path, +): + h = auth_headers(measurement_tec_user) + with patch("src.backend.api.routers.task_results.settings") as mock_settings: + mock_settings.upload_path = tmp_path + mock_settings.max_upload_size_mb = 1 + # tipo non ammesso (HTML dichiarato come png): come upload_file, 400 + resp = await client.post( + "/api/task-results/attachments", headers=h, + files={"file": ("x.png", b"", "image/png")}, + ) + assert resp.status_code == 400 + # oltre il limite + resp = await client.post( + "/api/task-results/attachments", headers=h, + files={"file": ("x.png", PNG + bytes(1024 * 1024 + 1), "image/png")}, + ) + assert resp.status_code == 400 + # un Maker senza ruolo di reparto non carica + resp = await client.post( + "/api/task-results/attachments", headers=auth_headers(maker_user), + files={"file": ("x.png", PNG, "image/png")}, + ) + assert resp.status_code == 403 + assert not any(tmp_path.rglob("*.png")) + + +async def test_task_results_dir_is_undeletable(client: AsyncClient, admin_user): + resp = await client.delete( + "/api/files/task_results/2026/10/abc.png", headers=auth_headers(admin_user), + ) + assert resp.status_code in (403, 404) + assert resp.status_code == 403