df1727be73
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
546 lines
19 KiB
Python
546 lines
19 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,
|
|
check_task_loop_options,
|
|
default_in_loop,
|
|
normalize_note_fields,
|
|
validate_tolerance_order,
|
|
)
|
|
from src.backend.models.orm.vision import VisionReferenceImage
|
|
from src.backend.services import recipe_service, vision_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
|
|
|
|
|
|
async def _task_for_write(db: AsyncSession, task: RecipeTask, user: User) -> RecipeTask:
|
|
"""Return the task to write to: the same one, or its copy in a new version.
|
|
|
|
A version that already has measurements cannot be edited on the spot - its
|
|
recorded pass/fail would silently start meaning something else. Copy-on-write
|
|
(the same one create_task/update_recipe use) gives the edit a fresh version
|
|
and leaves the measured one exactly as it was when the readings were taken.
|
|
The task is matched in the copy by its position (order_index), which
|
|
_copy_tasks_to_version preserves exactly.
|
|
"""
|
|
if not await recipe_service.version_has_measurements(db, task.version_id):
|
|
return task
|
|
|
|
recipe_id = task.recipe_id
|
|
if recipe_id is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Cannot determine the recipe for this task",
|
|
)
|
|
from src.backend.models.api.recipe import RecipeUpdate
|
|
new_version = await recipe_service.create_new_version(
|
|
db, recipe_id, RecipeUpdate(change_notes=f"Edited task: {task.title}"), user,
|
|
)
|
|
for candidate in new_version.tasks:
|
|
if candidate.order_index == task.order_index:
|
|
# TaskResponse.recipe_id reads task.version.recipe_id; new_version's
|
|
# own tasks are loaded via selectinload, which does not also
|
|
# populate the child->parent backref, so left alone this would
|
|
# lazy-load .version outside of an awaited context and crash.
|
|
candidate.version = new_version
|
|
return candidate
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Could not locate the task in the new version",
|
|
)
|
|
|
|
|
|
async def _subtask_for_write(
|
|
db: AsyncSession, task: RecipeTask, subtask: RecipeSubtask, user: User,
|
|
) -> tuple[RecipeTask, RecipeSubtask]:
|
|
"""Return (task, subtask) to write to: the same ones, or their copies.
|
|
|
|
Same copy-on-write as _task_for_write, one level down: once the task's copy
|
|
is found, the quote is matched inside it by marker_number (unique per task).
|
|
"""
|
|
new_task = await _task_for_write(db, task, user)
|
|
if new_task is task:
|
|
return task, subtask
|
|
|
|
for candidate in new_task.subtasks:
|
|
if candidate.marker_number == subtask.marker_number:
|
|
return new_task, candidate
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Could not locate the quote in the new 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.
|
|
"""
|
|
# #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()
|
|
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,
|
|
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()
|
|
|
|
# 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,
|
|
vision_output=sub_data.vision_output,
|
|
)
|
|
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.
|
|
|
|
If that version already has measurements the edit goes through copy-on-write:
|
|
a new version is created and its copy of the task is the one changed. The
|
|
version with measurements is left untouched, so its recorded pass/fail keeps
|
|
meaning what it meant when it was taken.
|
|
"""
|
|
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)
|
|
|
|
# #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():
|
|
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. Copy-on-write applies as in update_task: a version with
|
|
measurements is never touched, the deletion lands on its copy instead.
|
|
"""
|
|
task = await _get_task_or_404(db, task_id)
|
|
await _ensure_version_is_current(db, task.version_id)
|
|
|
|
task = await _task_for_write(db, task, user)
|
|
|
|
# The rows go with the task (migration 014 cascades them), the files do not:
|
|
# collect them now, remove the files only once the commit is durable - same
|
|
# order as vision.py::delete_reference_image. Rows are deleted explicitly so
|
|
# file_still_referenced sees the post-delete state even where the database
|
|
# does not enforce the cascade.
|
|
references = (await db.execute(
|
|
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
|
|
)).scalars().all()
|
|
for reference in references:
|
|
await db.delete(reference)
|
|
|
|
await db.delete(task)
|
|
await db.commit()
|
|
|
|
# A copy-on-write sibling may still point at the same file (#38.6).
|
|
for reference in references:
|
|
if not await vision_service.file_still_referenced(db, reference.path):
|
|
vision_service.remove_reference_file(reference)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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. Copy-on-write applies as in create_task: a version with
|
|
measurements gets a new version, and the subtask is added there instead.
|
|
"""
|
|
task = await _get_task_or_404(db, task_id)
|
|
await _ensure_version_is_current(db, task.version_id)
|
|
|
|
task = await _task_for_write(db, task, user)
|
|
|
|
# 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,
|
|
vision_output=data.vision_output,
|
|
)
|
|
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. Copy-on-write applies as in update_task, one level
|
|
down: a version with measurements is never touched, the quote's copy is.
|
|
"""
|
|
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)
|
|
|
|
# SubtaskUpdate is partial, so the order can only be checked against the
|
|
# values the write would actually leave in place: existing + incoming.
|
|
def _resulting(field: str) -> float | None:
|
|
value = update_data.get(field, getattr(subtask, field))
|
|
return None if value is None else float(value)
|
|
|
|
try:
|
|
validate_tolerance_order(
|
|
_resulting("ltl"), _resulting("lwl"), _resulting("nominal"),
|
|
_resulting("uwl"), _resulting("utl"),
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc),
|
|
)
|
|
|
|
task, subtask = await _subtask_for_write(db, task, subtask, user)
|
|
|
|
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. Copy-on-write applies as in update_task, one level
|
|
down: a version with measurements is never touched, the quote's copy is.
|
|
"""
|
|
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)
|
|
|
|
task, subtask = await _subtask_for_write(db, task, subtask, user)
|
|
|
|
await db.delete(subtask)
|
|
await db.flush()
|