feat: measurement workflow improvements and recipe update-in-place

- Auto-advance to next task after completing all subtask measurements
- 1s pause between measurements to show pass/fail/warning result
- Colored marker strip (green/red/amber) based on measurement status
- Replace duplicate measurements instead of appending (fixes progress bar)
- Add Task column and Date/Time column to measurement summary table
- Enrich summary with task_info for each measurement
- Update-in-place for recipe versions without measurements (no copy-on-write)
- Dark theme improvements and navbar cleanup
- Server config: ignore extra env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-09 19:36:42 +01:00
parent 4854966bf7
commit e8b88d48c1
9 changed files with 232 additions and 118 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ class Settings(BaseSettings):
"""Absolute path to upload directory."""
return Path(__file__).parent / self.upload_dir
model_config = {"env_file": "../.env", "env_file_encoding": "utf-8"}
model_config = {"env_file": "../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
settings = Settings()
+12 -3
View File
@@ -93,10 +93,19 @@ async def update_recipe(
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Update a recipe - creates a new version via copy-on-write. Requires Maker role."""
new_version = await recipe_service.create_new_version(db, recipe_id, data, user)
"""Update a recipe. Creates a new version only if the current one has measurements.
Otherwise updates in-place. Requires Maker role.
"""
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 to preserve measurement data
await recipe_service.create_new_version(db, recipe_id, data, user)
else:
# No measurements yet: update in-place
await recipe_service.update_current_version(db, recipe_id, data)
# Reload full recipe for consistent response
recipe = await recipe_service.get_recipe_detail(db, recipe_id)
return _recipe_to_response(recipe)
+13 -5
View File
@@ -153,11 +153,19 @@ async def create_task(
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
)
# 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 schemas.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)
+76
View File
@@ -357,6 +357,82 @@ async def get_measurement_count(
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)
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)
)
# 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,