From 6fbff2fe76915d7967ea6b85306d2be47885be7b Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 28 Jul 2026 18:15:18 +0000 Subject: [PATCH] feat(tasks): il tipo di un task si dichiara, non si deduce Punto 2 del documento modifiche del 28/07, prima richiesta di Menoncin. Il sistema distingueva un task di misura da uno documentale deducendolo: se aveva quote era una misura, altrimenti una nota. Conseguenza: un task di misura a cui le quote non erano ancora state inserite veniva trattato come nota, e il sistema si comportava in modo diverso a seconda di quanto fosse completa la ricetta. Nuovo campo task_type su recipe_tasks (migrazione 007) con nota, misura e disegno, piu' xf_compare e camera_measure gia' nell'enum: allargare un enum MySQL piu' avanti e' un ALTER su tabella viva, e non costa nulla prevederli adesso. Il backfill riproduce la classificazione che era a schermo, cosi' nessuna ricetta cambia comportamento all'aggiornamento: i task con quote diventano 'measure'; quelli senza quote ma con un disegno allegato diventano 'drawing' e non 'note', perche' e' gia' cio' che mostravano - chiamarli note sarebbe stato l'unico punto in cui questa migrazione cambiava le carte in tavola. Il tipo viene copiato esplicitamente nel copy-on-write del versioning: una nuova versione che lo perdesse riclassificherebbe in silenzio tutti i task alla prima modifica di una ricetta. Il task "Technical Drawing" creato d'ufficio quando si carica un disegno su una ricetta senza task nasce come 'drawing'. Lato operatore decide ora il tipo, non il conteggio delle quote: fermo linea, fine produzione, avvio produzione e la barra di produzione seguono il tipo. Dove servono davvero delle quote da mostrare - elenco marker, tastierino, fine ciclo misura - resta anche il controllo che ce ne sia almeno una, e un task di misura ancora privo di quote lo dichiara invece di somigliare a una nota. Nella lista task compare il tipo, cosi' si vede prima di aprire. La 007 e' stata eseguita su SQLite usa e getta con tre righe costruite apposta - una con quote, una col solo disegno, una nota secca - e il backfill le classifica come atteso. Il backfill girera' sui dati reali del cliente, provarlo a mano non bastava. Co-Authored-By: Claude Opus 5 (1M context) --- src/backend/api/routers/tasks.py | 1 + .../migrations/versions/007_add_task_type.py | 64 +++ src/backend/models/api/task.py | 10 +- src/backend/models/orm/task.py | 19 + src/backend/services/recipe_service.py | 4 + src/backend/tests/test_task_type.py | 171 ++++++++ .../templates/maker/task_editor.html | 31 +- .../templates/measure/task_execute.html | 50 ++- .../templates/measure/task_list.html | 20 +- .../translations/en/LC_MESSAGES/messages.po | 413 ++++++++++-------- .../translations/it/LC_MESSAGES/messages.po | 413 ++++++++++-------- .../flask_app/translations/messages.pot | 412 +++++++++-------- 12 files changed, 1019 insertions(+), 589 deletions(-) create mode 100644 src/backend/migrations/versions/007_add_task_type.py create mode 100644 src/backend/tests/test_task_type.py diff --git a/src/backend/api/routers/tasks.py b/src/backend/api/routers/tasks.py index 501dbba..7d1ad1f 100644 --- a/src/backend/api/routers/tasks.py +++ b/src/backend/api/routers/tasks.py @@ -172,6 +172,7 @@ async def create_task( new_task = RecipeTask( version_id=new_version.id, order_index=max_order + 1, + task_type=data.task_type, title=data.title, directive=data.directive, description=data.description, diff --git a/src/backend/migrations/versions/007_add_task_type.py b/src/backend/migrations/versions/007_add_task_type.py new file mode 100644 index 0000000..807d99f --- /dev/null +++ b/src/backend/migrations/versions/007_add_task_type.py @@ -0,0 +1,64 @@ +"""add an explicit type to recipe tasks + +Until now the kind of a task was deduced: quotes present meant a measurement, +otherwise a note. A measurement task whose quotes had not been entered yet was +therefore treated as a note - the system behaved differently depending on how +complete the recipe happened to be. + +Existing rows are classified by the rule that was in force, so nothing changes +behaviour on upgrade: tasks with quotes become 'measure'. Tasks with no quotes but +a drawing attached become 'drawing' rather than 'note' - that is what they already +displayed as, and calling them notes would be the one place this migration did +change behaviour. + +Revision ID: 007_task_type +Revises: 006_measurement_run +Create Date: 2026-07-28 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '007_task_type' +down_revision: Union[str, None] = '006_measurement_run' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +TASK_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure') + + +def upgrade() -> None: + op.add_column( + 'recipe_tasks', + sa.Column( + 'task_type', + sa.Enum(*TASK_TYPES, name='task_type_enum'), + nullable=False, + server_default='note', + ), + ) + op.create_index('ix_recipe_tasks_task_type', 'recipe_tasks', ['task_type']) + + # Backfill by the rule the frontend used, so no recipe changes behaviour. + op.execute( + """ + UPDATE recipe_tasks + SET task_type = 'measure' + WHERE id IN (SELECT DISTINCT task_id FROM recipe_subtasks) + """ + ) + op.execute( + """ + UPDATE recipe_tasks + SET task_type = 'drawing' + WHERE task_type = 'note' + AND file_path IS NOT NULL + """ + ) + + +def downgrade() -> None: + op.drop_index('ix_recipe_tasks_task_type', table_name='recipe_tasks') + op.drop_column('recipe_tasks', 'task_type') diff --git a/src/backend/models/api/task.py b/src/backend/models/api/task.py index 9119a14..f2e50de 100644 --- a/src/backend/models/api/task.py +++ b/src/backend/models/api/task.py @@ -1,8 +1,13 @@ """Pydantic schemas for RecipeTask and RecipeSubtask operations.""" -from typing import Any, Optional +from typing import Any, Literal, Optional from pydantic import BaseModel, ConfigDict, Field +from src.backend.models.orm.task import TASK_TYPES + +# Kept in step with the ORM enum so the two cannot drift apart. +TaskType = Literal[TASK_TYPES] + class SubtaskCreate(BaseModel): """Schema for creating a subtask.""" @@ -53,6 +58,7 @@ class SubtaskResponse(BaseModel): class TaskCreate(BaseModel): """Schema for creating a task.""" title: str = Field(..., min_length=1, max_length=255) + task_type: TaskType = "note" directive: Optional[str] = None description: Optional[str] = None file_path: Optional[str] = Field(None, max_length=500) @@ -64,6 +70,7 @@ class TaskCreate(BaseModel): class TaskUpdate(BaseModel): """Schema for updating a task.""" title: Optional[str] = Field(None, min_length=1, max_length=255) + task_type: Optional[TaskType] = None directive: Optional[str] = None description: Optional[str] = None file_path: Optional[str] = Field(None, max_length=500) @@ -79,6 +86,7 @@ class TaskResponse(BaseModel): version_id: int recipe_id: Optional[int] = None order_index: int + task_type: str title: str directive: Optional[str] = None description: Optional[str] = None diff --git a/src/backend/models/orm/task.py b/src/backend/models/orm/task.py index 7973943..9fdd6a3 100644 --- a/src/backend/models/orm/task.py +++ b/src/backend/models/orm/task.py @@ -12,6 +12,15 @@ if TYPE_CHECKING: from src.backend.models.orm.recipe import RecipeVersion +# 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", "xf_compare", "camera_measure") + +# Types the operator screen treats as "there are quotes to take here". +MEASURING_TASK_TYPES = ("measure", "camera_measure") + + class RecipeTask(Base): __tablename__ = "recipe_tasks" @@ -20,6 +29,16 @@ class RecipeTask(Base): Integer, ForeignKey("recipe_versions.id"), nullable=False ) order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # Declared, not deduced. The old rule - "has subtasks, therefore a measurement" - + # meant a measurement task whose quotes had not been entered yet behaved as a + # note, so the system acted differently depending on how finished the recipe was. + task_type: Mapped[str] = mapped_column( + Enum(*TASK_TYPES, name="task_type_enum"), + nullable=False, + default="note", + server_default="note", + index=True, + ) title: Mapped[str] = mapped_column(String(255), nullable=False) directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) diff --git a/src/backend/services/recipe_service.py b/src/backend/services/recipe_service.py index 8065804..8d40812 100644 --- a/src/backend/services/recipe_service.py +++ b/src/backend/services/recipe_service.py @@ -55,6 +55,9 @@ async def _copy_tasks_to_version( new_task = RecipeTask( version_id=target_version.id, order_index=task.order_index, + # Carried over explicitly: a copy-on-write version that dropped the type + # would silently reclassify every task the moment a recipe is edited. + task_type=task.task_type, title=task.title, directive=task.directive, description=task.description, @@ -254,6 +257,7 @@ async def create_new_version( default_task = RecipeTask( version_id=new_version.id, order_index=0, + task_type="drawing", title="Technical Drawing", file_path=data.file_path, file_type=data.file_type or ( diff --git a/src/backend/tests/test_task_type.py b/src/backend/tests/test_task_type.py new file mode 100644 index 0000000..f20e3dd --- /dev/null +++ b/src/backend/tests/test_task_type.py @@ -0,0 +1,171 @@ +"""Tests for the explicit task type (point 2). + +The type used to be deduced - quotes present meant a measurement - so a measurement +task whose quotes had not been entered yet was treated as a note, and the system +behaved differently depending on how complete the recipe was. These tests pin the +type down as something declared and carried. +""" +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from src.backend.models.orm.recipe import RecipeVersion +from src.backend.models.orm.task import RecipeTask +from src.backend.tests.conftest import auth_headers, create_test_recipe + + +async def _current_version(db_session, recipe_id: int) -> RecipeVersion: + row = await db_session.execute( + select(RecipeVersion).where( + RecipeVersion.recipe_id == recipe_id, + RecipeVersion.is_current == True, # noqa: E712 + ) + ) + return row.scalar_one() + + +async def test_task_defaults_to_note(client: AsyncClient, maker_user, db_session): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-T1") + await db_session.commit() + + resp = await client.post( + f"/api/recipes/{recipe.id}/tasks", + headers=auth_headers(maker_user), + json={"title": "Solo una nota"}, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["task_type"] == "note" + + +@pytest.mark.parametrize("declared", ["note", "measure", "drawing"]) +async def test_task_keeps_the_declared_type( + client: AsyncClient, maker_user, db_session, declared, +): + recipe = await create_test_recipe( + db_session, user_id=maker_user.id, code=f"REC-{declared}", + ) + await db_session.commit() + + resp = await client.post( + f"/api/recipes/{recipe.id}/tasks", + headers=auth_headers(maker_user), + json={"title": f"Task {declared}", "task_type": declared}, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["task_type"] == declared + + +async def test_measure_task_without_quotes_is_still_a_measure_task( + client: AsyncClient, maker_user, db_session, +): + """The criterion from the document: an unfinished recipe must not change kind.""" + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-NOQ") + await db_session.commit() + + created = await client.post( + f"/api/recipes/{recipe.id}/tasks", + headers=auth_headers(maker_user), + json={"title": "Misura da compilare", "task_type": "measure"}, + ) + task_id = created.json()["id"] + + fetched = await client.get(f"/api/tasks/{task_id}", headers=auth_headers(maker_user)) + body = fetched.json() + assert body["subtasks"] == [], "il task non ha ancora quote" + assert body["task_type"] == "measure", "e resta comunque un task di misura" + + +async def test_unknown_type_is_rejected(client: AsyncClient, maker_user, db_session): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-BAD") + await db_session.commit() + + resp = await client.post( + f"/api/recipes/{recipe.id}/tasks", + headers=auth_headers(maker_user), + json={"title": "x", "task_type": "qualcosa"}, + ) + assert resp.status_code == 422 + + +async def test_type_can_be_changed(client: AsyncClient, maker_user, db_session): + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-CHG") + await db_session.commit() + created = await client.post( + f"/api/recipes/{recipe.id}/tasks", + headers=auth_headers(maker_user), + json={"title": "Nasce nota", "task_type": "note"}, + ) + task_id = created.json()["id"] + + resp = await client.put( + f"/api/tasks/{task_id}", + headers=auth_headers(maker_user), + json={"task_type": "measure"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["task_type"] == "measure" + + +async def test_type_survives_copy_on_write_versioning( + client: AsyncClient, maker_user, measurement_tec_user, db_session, +): + """A new version must not silently reclassify every task of the recipe.""" + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-COW") + await db_session.commit() + + version = await _current_version(db_session, recipe.id) + row = await db_session.execute( + select(RecipeTask).where(RecipeTask.version_id == version.id) + ) + first_task = row.scalars().first() + + # Declare it a drawing. The point is that this survives, not how it got set. + await client.put( + f"/api/tasks/{first_task.id}", + headers=auth_headers(maker_user), + json={"task_type": "drawing"}, + ) + + # Measure on the current version, which forces the next edit to copy-on-write. + await client.post( + "/api/measurements/", + headers=auth_headers(measurement_tec_user), + json={ + "subtask_id": first_task.subtasks[0].id, + "version_id": version.id, + "value": 10.0, + }, + ) + + # Editing now must create a new version. + await client.put( + f"/api/recipes/{recipe.id}", + headers=auth_headers(maker_user), + json={"name": "Rinominata"}, + ) + + new_version = await _current_version(db_session, recipe.id) + assert new_version.id != version.id, "l'edit doveva creare una nuova versione" + + copied = await db_session.execute( + select(RecipeTask).where(RecipeTask.version_id == new_version.id) + ) + types = sorted(t.task_type for t in copied.scalars().all()) + assert "drawing" in types, "il tipo dichiarato non e' sopravvissuto alla copia" + + +async def test_task_list_exposes_the_type(client: AsyncClient, maker_user, db_session): + """The operator's task list reads the type, so it must come over the wire.""" + recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-LST") + await db_session.commit() + await client.post( + f"/api/recipes/{recipe.id}/tasks", + headers=auth_headers(maker_user), + json={"title": "Misura", "task_type": "measure"}, + ) + + resp = await client.get( + f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user), + ) + assert resp.status_code == 200 + assert all("task_type" in t for t in resp.json()) diff --git a/src/frontend/flask_app/templates/maker/task_editor.html b/src/frontend/flask_app/templates/maker/task_editor.html index bbd30ac..0badd37 100644 --- a/src/frontend/flask_app/templates/maker/task_editor.html +++ b/src/frontend/flask_app/templates/maker/task_editor.html @@ -270,6 +270,18 @@ class="tmf-input" placeholder="{{ _('Es. Controllo dimensionale flangia') }}"> + +
+ + +

+ {{ _('Un task di misura resta tale anche prima di inserire le quote') }} +

+
@@ -516,6 +528,14 @@