"""Recipe service - business logic for copy-on-write versioning.""" import math from typing import Optional from fastapi import HTTPException, status from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from models.measurement import Measurement from models.recipe import Recipe, RecipeVersion from models.setting import RecipeVersionAudit from models.task import RecipeSubtask, RecipeTask from models.user import User from schemas.recipe import RecipeCreate, RecipeUpdate # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe: """Return a recipe or raise 404.""" 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") return recipe async def _get_current_version( db: AsyncSession, recipe_id: int ) -> Optional[RecipeVersion]: """Return the current version for a recipe (with tasks/subtasks loaded).""" 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) ) ) return result.scalar_one_or_none() async def _copy_tasks_to_version( db: AsyncSession, source_version: RecipeVersion, target_version: RecipeVersion, ) -> None: """Deep-copy all tasks and subtasks from *source* to *target* version.""" for task in source_version.tasks: new_task = RecipeTask( version_id=target_version.id, order_index=task.order_index, title=task.title, directive=task.directive, description=task.description, file_path=task.file_path, file_type=task.file_type, annotations_json=task.annotations_json, ) db.add(new_task) await db.flush() # get new_task.id for sub in task.subtasks: new_sub = RecipeSubtask( task_id=new_task.id, marker_number=sub.marker_number, description=sub.description, measurement_type=sub.measurement_type, nominal=sub.nominal, utl=sub.utl, uwl=sub.uwl, lwl=sub.lwl, ltl=sub.ltl, unit=sub.unit, ) db.add(new_sub) await db.flush() async def _write_audit( db: AsyncSession, recipe_id: int, old_version_id: Optional[int], new_version_id: int, changed_by: int, change_type: str, change_reason: Optional[str] = None, ) -> None: """Persist an audit record for a version change.""" audit = RecipeVersionAudit( recipe_id=recipe_id, old_version_id=old_version_id, new_version_id=new_version_id, changed_by=changed_by, change_type=change_type, change_reason=change_reason, ) db.add(audit) await db.flush() # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- async def create_recipe( db: AsyncSession, data: RecipeCreate, user: User, ) -> Recipe: """Create a recipe together with its first version (v1). Returns the newly created Recipe with the current version loaded. """ # Check code uniqueness existing = await db.execute( select(Recipe).where(Recipe.code == data.code) ) if existing.scalar_one_or_none() is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Recipe code '{data.code}' already exists", ) recipe = Recipe( code=data.code, name=data.name, description=data.description, created_by=user.id, ) db.add(recipe) await db.flush() version = RecipeVersion( recipe_id=recipe.id, version_number=1, is_current=True, created_by=user.id, change_notes="Initial version", ) db.add(version) await db.flush() # Create initial task with technical drawing if file was uploaded if data.file_path: initial_task = RecipeTask( version_id=version.id, order_index=0, title="Technical Drawing", file_path=data.file_path, file_type=data.file_type or ( "pdf" if data.file_path.lower().endswith(".pdf") else "image" ), annotations_json=data.annotations_json, ) db.add(initial_task) await db.flush() await _write_audit( db, recipe_id=recipe.id, old_version_id=None, new_version_id=version.id, changed_by=user.id, change_type="CREATE", change_reason="Recipe created", ) # Reload recipe with versions + tasks eagerly loaded result = await db.execute( select(Recipe) .where(Recipe.id == recipe.id) .options( selectinload(Recipe.versions) .selectinload(RecipeVersion.tasks) .selectinload(RecipeTask.subtasks) ) ) return result.scalar_one() async def create_new_version( db: AsyncSession, recipe_id: int, data: RecipeUpdate, user: User, ) -> RecipeVersion: """Copy-on-write: create a new version by cloning current tasks/subtasks. 1. Fetch current version (with tasks + subtasks) 2. Create a new RecipeVersion with incremented version_number 3. Deep-copy all tasks + subtasks into the new version 4. Apply updates from *data* to the recipe header 5. Flip is_current: old -> False, new -> True 6. Write audit trail """ recipe = await _get_recipe_or_404(db, recipe_id) if not recipe.active: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Cannot update an inactive recipe", ) current = await _get_current_version(db, recipe_id) if current is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No current version found for this recipe", ) old_version_id = current.id new_version_number = current.version_number + 1 # Mark old version as non-current await db.execute( update(RecipeVersion) .where(RecipeVersion.id == current.id) .values(is_current=False) ) # Create new version new_version = RecipeVersion( recipe_id=recipe_id, version_number=new_version_number, is_current=True, created_by=user.id, change_notes=data.change_notes, ) db.add(new_version) await db.flush() # Deep-copy tasks + subtasks await _copy_tasks_to_version(db, source_version=current, target_version=new_version) # Apply task-level updates (file_path, annotations_json) to the first task if data.file_path is not None or data.annotations_json is not None: # Reload new version's tasks result_tasks = await db.execute( select(RecipeTask) .where(RecipeTask.version_id == new_version.id) .order_by(RecipeTask.order_index) ) tasks = list(result_tasks.scalars().all()) if tasks: first_task = tasks[0] if data.file_path is not None: first_task.file_path = data.file_path # Auto-detect file_type from extension if data.file_type: first_task.file_type = data.file_type elif data.file_path.lower().endswith(".pdf"): first_task.file_type = "pdf" else: first_task.file_type = "image" if data.annotations_json is not None: first_task.annotations_json = data.annotations_json else: # No tasks yet — create a default task to hold the drawing default_task = RecipeTask( version_id=new_version.id, order_index=0, title="Technical Drawing", file_path=data.file_path, file_type=data.file_type or ( "pdf" if data.file_path and data.file_path.lower().endswith(".pdf") else "image" ), annotations_json=data.annotations_json, ) db.add(default_task) await db.flush() # Apply header updates update_fields: dict = {} if data.name is not None: update_fields["name"] = data.name if data.description is not None: update_fields["description"] = data.description if update_fields: await db.execute( update(Recipe).where(Recipe.id == recipe_id).values(**update_fields) ) # Audit await _write_audit( db, recipe_id=recipe_id, old_version_id=old_version_id, new_version_id=new_version.id, changed_by=user.id, change_type="UPDATE", change_reason=data.change_notes, ) await db.flush() # Reload the version with tasks for the response result = await db.execute( select(RecipeVersion) .where(RecipeVersion.id == new_version.id) .options( selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks) ) ) return result.scalar_one() async def get_current_version( db: AsyncSession, recipe_id: int ) -> RecipeVersion: """Return the current version with tasks and subtasks. Raises 404 if the recipe or its current version cannot be found. """ await _get_recipe_or_404(db, recipe_id) version = await _get_current_version(db, recipe_id) 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_measurement_count( db: AsyncSession, recipe_id: int, version_number: int ) -> int: """Return the number of measurements recorded against a specific version.""" # Resolve version_id from recipe_id + version_number result = await db.execute( select(RecipeVersion.id).where( RecipeVersion.recipe_id == recipe_id, RecipeVersion.version_number == version_number, ) ) version_id = result.scalar_one_or_none() if version_id is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Version {version_number} not found for recipe {recipe_id}", ) count_result = await db.execute( select(func.count()).select_from(Measurement).where( Measurement.version_id == version_id ) ) return count_result.scalar_one() async def list_recipes( db: AsyncSession, page: int = 1, per_page: int = 20, search: Optional[str] = None, ) -> dict: """Return a paginated list of active recipes. Each recipe includes its current version (with tasks). """ base_filter = [Recipe.active == True] # noqa: E712 if search: like_pattern = f"%{search}%" base_filter.append( (Recipe.name.ilike(like_pattern)) | (Recipe.code.ilike(like_pattern)) ) # Total count count_stmt = select(func.count()).select_from(Recipe).where(*base_filter) total = (await db.execute(count_stmt)).scalar_one() pages = max(1, math.ceil(total / per_page)) offset = (page - 1) * per_page # Fetch recipes stmt = ( select(Recipe) .where(*base_filter) .order_by(Recipe.name.asc()) .offset(offset) .limit(per_page) ) result = await db.execute(stmt) recipes = result.scalars().all() return { "items": recipes, "total": total, "page": page, "per_page": per_page, "pages": pages, } async def get_recipe_detail(db: AsyncSession, recipe_id: int) -> Recipe: """Return a single recipe with its current version + tasks loaded.""" result = await db.execute( select(Recipe) .where(Recipe.id == recipe_id) .options( selectinload(Recipe.versions) .selectinload(RecipeVersion.tasks) .selectinload(RecipeTask.subtasks) ) ) recipe = result.scalar_one_or_none() if recipe is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found" ) return recipe async def get_recipe_by_code(db: AsyncSession, code: str) -> Recipe: """Return a recipe by its barcode/code, with current version loaded.""" result = await db.execute( select(Recipe) .where(Recipe.code == code, Recipe.active == True) # noqa: E712 .options( selectinload(Recipe.versions) .selectinload(RecipeVersion.tasks) .selectinload(RecipeTask.subtasks) ) ) recipe = result.scalar_one_or_none() if recipe is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No active recipe found with code '{code}'", ) return recipe async def deactivate_recipe( db: AsyncSession, recipe_id: int, user: User ) -> Recipe: """Soft-delete a recipe by setting active=False.""" recipe = await _get_recipe_or_404(db, recipe_id) if not recipe.active: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Recipe is already inactive", ) await db.execute( update(Recipe).where(Recipe.id == recipe_id).values(active=False) ) # Audit current = await _get_current_version(db, recipe_id) if current is not None: await _write_audit( db, recipe_id=recipe_id, old_version_id=current.id, new_version_id=current.id, changed_by=user.id, change_type="RETIRE", change_reason="Recipe deactivated", ) await db.flush() await db.refresh(recipe) return recipe async def list_versions( db: AsyncSession, recipe_id: int ) -> list[RecipeVersion]: """Return all versions of a recipe, ordered by version_number desc.""" await _get_recipe_or_404(db, recipe_id) result = await db.execute( select(RecipeVersion) .where(RecipeVersion.recipe_id == recipe_id) .order_by(RecipeVersion.version_number.desc()) .options( selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks) ) ) return list(result.scalars().all()) async def get_version_detail( db: AsyncSession, recipe_id: int, version_number: int ) -> RecipeVersion: """Return a specific version of a recipe with tasks loaded.""" await _get_recipe_or_404(db, recipe_id) result = await db.execute( select(RecipeVersion) .where( RecipeVersion.recipe_id == recipe_id, RecipeVersion.version_number == version_number, ) .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=f"Version {version_number} not found for recipe {recipe_id}", ) return version