Files
TieMeasureFlow/src/backend/api/routers/tasks.py
T
Adriano Dal Pastro 6fbff2fe76 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>
2026-07-28 18:15:18 +00:00

397 lines
13 KiB
Python

"""Task and Subtask router - CRUD, reorder, copy-on-write integration."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_maker, get_current_user
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User
from src.backend.models.api.task import (
SubtaskCreate,
SubtaskResponse,
SubtaskUpdate,
TaskCreate,
TaskReorderRequest,
TaskResponse,
TaskUpdate,
)
from src.backend.services import recipe_service
router = APIRouter(tags=["tasks"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _get_current_version_for_recipe(
db: AsyncSession, recipe_id: int
) -> RecipeVersion:
"""Return the current version of a recipe or raise 404."""
result = await db.execute(
select(RecipeVersion)
.where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
.options(
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
)
)
version = result.scalar_one_or_none()
if version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No current version found for this recipe",
)
return version
async def _get_task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
"""Return a task with its subtasks or raise 404."""
result = await db.execute(
select(RecipeTask)
.where(RecipeTask.id == task_id)
.options(
selectinload(RecipeTask.subtasks),
selectinload(RecipeTask.version),
)
)
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
return task
async def _get_subtask_or_404(db: AsyncSession, subtask_id: int) -> RecipeSubtask:
"""Return a subtask or raise 404."""
result = await db.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
)
subtask = result.scalar_one_or_none()
if subtask is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Subtask not found"
)
return subtask
async def _ensure_version_is_current(db: AsyncSession, version_id: int) -> RecipeVersion:
"""Verify a version is the current one (editable). Raise 409 otherwise."""
result = await db.execute(
select(RecipeVersion).where(RecipeVersion.id == version_id)
)
version = result.scalar_one_or_none()
if version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Version not found"
)
if not version.is_current:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot modify a non-current version. Create a new version first.",
)
return version
# ---------------------------------------------------------------------------
# Task endpoints
# ---------------------------------------------------------------------------
@router.get("/api/recipes/{recipe_id}/tasks", response_model=list[TaskResponse])
async def list_tasks(
recipe_id: int,
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List all tasks for the current version of a recipe. All authenticated users."""
version = await _get_current_version_for_recipe(db, recipe_id)
tasks = sorted(version.tasks, key=lambda t: t.order_index)
return [TaskResponse.model_validate(t) for t in tasks]
@router.get("/api/tasks/{task_id}", response_model=TaskResponse)
async def get_task(
task_id: int,
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a single task with its subtasks. All authenticated users."""
task = await _get_task_or_404(db, task_id)
return TaskResponse.model_validate(task)
@router.post(
"/api/recipes/{recipe_id}/tasks",
response_model=TaskResponse,
status_code=201,
)
async def create_task(
recipe_id: int,
data: TaskCreate,
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Add a task to the current version of a recipe.
This creates a NEW version via copy-on-write, then appends the task to
the new version. Requires Maker role.
"""
# Ensure recipe exists and is active
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
recipe = result.scalar_one_or_none()
if recipe is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
if not recipe.active:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot modify an inactive recipe",
)
# Check if current version has measurements
current = await recipe_service.get_current_version(db, recipe_id)
has_measurements = await recipe_service.version_has_measurements(db, current.id)
if has_measurements:
# Copy-on-write: create new version preserving measurement data
from src.backend.models.api.recipe import RecipeUpdate
new_version = await recipe_service.create_new_version(
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
)
else:
# No measurements: use current version directly
new_version = current
# Determine order_index (append at end)
max_order = max((t.order_index for t in new_version.tasks), default=-1)
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,
file_path=data.file_path,
file_type=data.file_type,
annotations_json=data.annotations_json,
)
db.add(new_task)
await db.flush()
# Create subtasks if provided
for sub_data in data.subtasks:
sub = RecipeSubtask(
task_id=new_task.id,
marker_number=sub_data.marker_number,
description=sub_data.description,
measurement_type=sub_data.measurement_type,
nominal=sub_data.nominal,
utl=sub_data.utl,
uwl=sub_data.uwl,
lwl=sub_data.lwl,
ltl=sub_data.ltl,
unit=sub_data.unit,
image_path=sub_data.image_path,
)
db.add(sub)
await db.flush()
await db.refresh(new_task, attribute_names=["subtasks"])
return TaskResponse.model_validate(new_task)
@router.put("/api/tasks/reorder", response_model=list[TaskResponse])
async def reorder_tasks(
data: TaskReorderRequest,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Reorder tasks by providing ordered task IDs. All tasks must belong to the
same current version. Requires Maker role.
"""
if not data.task_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="task_ids must not be empty",
)
# Fetch all tasks
result = await db.execute(
select(RecipeTask)
.where(RecipeTask.id.in_(data.task_ids))
.options(
selectinload(RecipeTask.subtasks),
selectinload(RecipeTask.version),
)
)
tasks_map = {t.id: t for t in result.scalars().all()}
# Validate all ids found
for tid in data.task_ids:
if tid not in tasks_map:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task {tid} not found",
)
# Validate all belong to the same version and version is current
version_ids = {t.version_id for t in tasks_map.values()}
if len(version_ids) != 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="All tasks must belong to the same version",
)
await _ensure_version_is_current(db, version_ids.pop())
# Apply new order
for idx, tid in enumerate(data.task_ids):
tasks_map[tid].order_index = idx
await db.flush()
ordered = [tasks_map[tid] for tid in data.task_ids]
return [TaskResponse.model_validate(t) for t in ordered]
@router.put("/api/tasks/{task_id}", response_model=TaskResponse)
async def update_task(
task_id: int,
data: TaskUpdate,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Update a task. The task must belong to the current version. Requires Maker role."""
task = await _get_task_or_404(db, task_id)
await _ensure_version_is_current(db, task.version_id)
update_data = data.model_dump(exclude_unset=True)
if not update_data:
return TaskResponse.model_validate(task)
for field, value in update_data.items():
setattr(task, field, value)
await db.flush()
await db.refresh(task, attribute_names=["subtasks"])
return TaskResponse.model_validate(task)
@router.delete("/api/tasks/{task_id}", status_code=204)
async def delete_task(
task_id: int,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Delete a task and its subtasks. The task must belong to the current version.
Requires Maker role.
"""
task = await _get_task_or_404(db, task_id)
await _ensure_version_is_current(db, task.version_id)
await db.delete(task)
await db.flush()
# ---------------------------------------------------------------------------
# Subtask endpoints
# ---------------------------------------------------------------------------
@router.post(
"/api/tasks/{task_id}/subtasks",
response_model=SubtaskResponse,
status_code=201,
)
async def create_subtask(
task_id: int,
data: SubtaskCreate,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Add a subtask to a task. The task must belong to the current version.
Requires Maker role.
"""
task = await _get_task_or_404(db, task_id)
await _ensure_version_is_current(db, task.version_id)
# Check marker_number uniqueness within the task
existing_markers = {s.marker_number for s in task.subtasks}
if data.marker_number in existing_markers:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Marker number {data.marker_number} already exists on this task",
)
subtask = RecipeSubtask(
task_id=task_id,
marker_number=data.marker_number,
description=data.description,
measurement_type=data.measurement_type,
nominal=data.nominal,
utl=data.utl,
uwl=data.uwl,
lwl=data.lwl,
ltl=data.ltl,
unit=data.unit,
image_path=data.image_path,
)
db.add(subtask)
await db.flush()
await db.refresh(subtask)
return SubtaskResponse.model_validate(subtask)
@router.put("/api/subtasks/{subtask_id}", response_model=SubtaskResponse)
async def update_subtask(
subtask_id: int,
data: SubtaskUpdate,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Update a subtask. Its parent task must belong to the current version.
Requires Maker role.
"""
subtask = await _get_subtask_or_404(db, subtask_id)
# Validate task belongs to current version
task = await _get_task_or_404(db, subtask.task_id)
await _ensure_version_is_current(db, task.version_id)
update_data = data.model_dump(exclude_unset=True)
if not update_data:
return SubtaskResponse.model_validate(subtask)
for field, value in update_data.items():
setattr(subtask, field, value)
await db.flush()
await db.refresh(subtask)
return SubtaskResponse.model_validate(subtask)
@router.delete("/api/subtasks/{subtask_id}", status_code=204)
async def delete_subtask(
subtask_id: int,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Delete a subtask. Its parent task must belong to the current version.
Requires Maker role.
"""
subtask = await _get_subtask_or_404(db, subtask_id)
# Validate task belongs to current version
task = await _get_task_or_404(db, subtask.task_id)
await _ensure_version_is_current(db, task.version_id)
await db.delete(subtask)
await db.flush()