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 @@