chore(v2): restructure monorepo to src/ layout with uv
Aligns the repo with the python-project-spec-design.md template chosen for V2.0.0. Big move, no logic changes. The 3 pre-existing test failures (test_recipes::test_update_recipe, test_recipes:: test_recipe_versioning, test_tasks::test_reorder_tasks, plus the client test_save_measurement_proxy) survive unchanged. Layout changes - server/ -> src/backend/ - server/middleware/ -> src/backend/api/middleware/ - server/routers/ -> src/backend/api/routers/ - server/models/ -> src/backend/models/orm/ - server/schemas/ -> src/backend/models/api/ - server/uploads/ -> uploads/ (project root, mounted volume) - server/tests/ -> src/backend/tests/ - client/ -> src/frontend/flask_app/ (Flask kept; React deroga is documented in CLAUDE.md, justified by tablet UX, USB caliper/barcode workflow and Fabric.js integration) Tooling - pyproject.toml: monorepo with [project] core deps and optional-dependencies server / client / dev. Replaces both server/requirements.txt and client/requirements.txt. - uv.lock + .python-version (3.11) committed for reproducible builds. - Dockerfile (root, backend) and Dockerfile.frontend rewritten to use uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles preserved as Dockerfile.legacy for reference but excluded from build context via .dockerignore. - docker-compose.dev.yml + docker-compose.yml: build context now ".", dockerfile pointing to the root files. Code adjustments forced by the move - Every "from config|database|models|schemas|services|routers|middleware import ..." rewritten to its src.backend.* equivalent (50+ files including indented inline imports inside test bodies). - src/backend/migrations/env.py: insert project root into sys.path so alembic can resolve src.backend.* imports regardless of cwd. - src/backend/config.py: env_file ../../.env (was ../.env), upload_path resolves project root via parents[2]. - src/backend/tests/conftest.py + tests: import ... from src.backend.* instead of bare names; old per-directory pytest.ini files removed in favor of root pyproject.toml [tool.pytest.ini_options]. - .gitignore: uploads/ at root, src/frontend/flask_app/static/css/ tailwind.css path; .dockerignore tightened. - CLAUDE.md: rewrote sections "Layout del repository", "Comandi di Sviluppo", "Database & Migrations", "Test", "i18n", and all path references throughout the architecture sections. Verified - uv lock resolves 77 packages; uv sync --extra server --extra client --extra dev installs cleanly. - uv run pytest: 171 passed, 4 pre-existing failures. - uv run alembic -c src/backend/migrations/alembic.ini check loads config and metadata (errors only on the absent local MySQL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
"""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,
|
||||
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()
|
||||
Reference in New Issue
Block a user