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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 18:15:18 +00:00
parent 7bc3c1f938
commit 6fbff2fe76
12 changed files with 1019 additions and 589 deletions
+1
View File
@@ -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,
@@ -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')
+9 -1
View File
@@ -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
+19
View File
@@ -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)
+4
View File
@@ -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 (
+171
View File
@@ -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())