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/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..ee7fa3b --- /dev/null +++ b/src/backend/api/routers/task_results.py @@ -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] 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