"""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", ) # 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/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, ) 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()