feat: FASE 1 - Backend Core (modelli, auth, API)
Implementazione completa del backend FastAPI: - Modelli SQLAlchemy: User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, AccessLog, SystemSetting, RecipeVersionAudit - Schemas Pydantic v2 per tutti i CRUD + statistiche SPC - Middleware: API Key auth (X-API-Key) con role checking + access logging - Router: auth, users, recipes, tasks, measurements, files, settings - Services: auth (bcrypt+secrets), recipe (copy-on-write versioning), measurement (auto pass/fail con UTL/UWL/LWL/LTL) - Alembic env.py con import modelli attivi - Fix architect review: no double-commit, recipe_id subquery filter, user_id in access logs, type annotations corrette Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
"""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))
|
||||
)
|
||||
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.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",
|
||||
)
|
||||
|
||||
# Copy-on-write: create new version with existing tasks cloned
|
||||
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
|
||||
)
|
||||
|
||||
# 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_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,
|
||||
)
|
||||
db.add(sub)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(new_task, attribute_names=["subtasks"])
|
||||
return TaskResponse.model_validate(new_task)
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
@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]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,
|
||||
)
|
||||
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()
|
||||
Reference in New Issue
Block a user