Files
TieMeasureFlow/server/services/recipe_service.py
T
Adriano dbfb5591c5 fix: separate recipe preview image from task images
Recipe image_path is now used as preview thumbnail only. Removed
auto-creation of "Technical Drawing" task from recipe upload, and
removed recipe image strip from task_execute view. Each task displays
its own file_path independently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:29:27 +01:00

586 lines
18 KiB
Python

"""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,
image_path=sub.image_path,
)
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,
image_path=data.image_path,
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()
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 data.image_path is not None:
update_fields["image_path"] = data.image_path
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 version_has_measurements(db: AsyncSession, version_id: int) -> bool:
"""Check if any measurements exist for the given version."""
result = await db.execute(
select(func.count()).select_from(Measurement).where(
Measurement.version_id == version_id
)
)
return result.scalar_one() > 0
async def update_current_version(
db: AsyncSession,
recipe_id: int,
data: RecipeUpdate,
) -> RecipeVersion:
"""Update recipe header in-place on the current version (no copy-on-write)."""
# Apply header updates (name, description, image_path)
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 data.image_path is not None:
update_fields["image_path"] = data.image_path
if update_fields:
await db.execute(
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
)
# Apply task-level file updates to first task (same logic as create_new_version)
current = await _get_current_version(db, recipe_id)
if data.file_path is not None or data.annotations_json is not None:
result_tasks = await db.execute(
select(RecipeTask)
.where(RecipeTask.version_id == current.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
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=current.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()
# Reload version with tasks for response
result = await db.execute(
select(RecipeVersion)
.where(RecipeVersion.id == current.id)
.options(
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
)
)
return 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 with versions and tasks for thumbnail display
stmt = (
select(Recipe)
.where(*base_filter)
.options(
selectinload(Recipe.versions)
.selectinload(RecipeVersion.tasks)
)
.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