Files
TieMeasureFlow/server/routers/tasks.py
Adriano 5cc576cec9 feat: add image_path to recipe/subtask and user password change API
- Recipe model: add image_path field for recipe-level image
- RecipeSubtask model: add image_path for per-subtask detail images
- Schemas: add image_path to create/update/response for recipe and subtask
- Task router: pass image_path when creating tasks and subtasks
- Recipe service: copy image_path in versioning and update-in-place
- Users router: add PUT /{user_id}/password endpoint (admin only)
- User schema: add UserPasswordChange model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:58:41 +01:00

393 lines
12 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 database import get_db
from middleware.api_key import require_maker, get_current_user
from models.recipe import Recipe, RecipeVersion
from models.task import RecipeSubtask, RecipeTask
from models.user import User
from schemas.task import (
SubtaskCreate,
SubtaskResponse,
SubtaskUpdate,
TaskCreate,
TaskReorderRequest,
TaskResponse,
TaskUpdate,
)
from 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 schemas.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,
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))
)
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()