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
+42 -9
View File
@@ -121,11 +121,21 @@ def task_execute(task_id: int):
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
# Load all task IDs for this recipe (ordered) for auto-advance
recipe_id = task_resp.get("recipe_id")
all_task_ids = []
if recipe_id:
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, list):
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
all_task_ids = [t["id"] for t in sorted_tasks]
return render_template(
"measure/task_execute.html",
task=task_resp,
lot_number=lot_number,
serial_number=serial_number,
all_task_ids=all_task_ids,
)
@@ -150,18 +160,44 @@ def task_complete(recipe_id: int):
)
return redirect(url_for("measure.select_recipe"))
# Load tasks+subtasks for this recipe to build subtask and task lookup
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
subtask_map = {}
subtask_task_map = {} # subtask_id → task info
if isinstance(tasks_resp, list):
for task in tasks_resp:
for st in task.get("subtasks", []):
subtask_map[st["id"]] = st
subtask_task_map[st["id"]] = {
"id": task["id"],
"title": task.get("title", ""),
"order_index": task.get("order_index", 0),
}
# Load measurements if version_id provided
measurements = []
if version_id:
meas_resp = api_client.get(
"/api/measurements",
params={"version_id": version_id},
params={"version_id": version_id, "per_page": 500},
)
if not (isinstance(meas_resp, dict) and meas_resp.get("error")):
measurements = (
raw = (
meas_resp if isinstance(meas_resp, list)
else meas_resp.get("items", [])
)
# Enrich each measurement with nested subtask data
for m in raw:
st = subtask_map.get(m.get("subtask_id"), {})
m["subtask"] = st
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
# Compute deviation if not present
if m.get("deviation") is None and st.get("nominal") is not None:
try:
m["deviation"] = m["value"] - st["nominal"]
except (TypeError, KeyError):
m["deviation"] = 0.0
measurements = raw
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
@@ -243,25 +279,22 @@ def save_measurement():
# Validate required fields
subtask_id = data.get("subtask_id")
task_id = data.get("task_id")
version_id = data.get("version_id")
value = data.get("value")
if subtask_id is None or task_id is None or value is None:
if subtask_id is None or version_id is None or value is None:
return jsonify({
"error": True,
"detail": _("Dati mancanti: subtask_id, task_id e value sono obbligatori"),
"detail": _("Dati mancanti: subtask_id, version_id e value sono obbligatori"),
}), 400
# Build payload for the FastAPI backend
payload = {
"subtask_id": subtask_id,
"task_id": task_id,
"version_id": version_id,
"value": value,
"pass_fail": data.get("pass_fail", ""),
"deviation": data.get("deviation"),
"lot_number": data.get("lot_number", session.get("lot_number", "")),
"serial_number": data.get("serial_number", session.get("serial_number", "")),
"measured_by": session.get("user_id"),
"input_method": data.get("input_method", "manual"),
}
+21 -1
View File
@@ -38,6 +38,15 @@
/* ---- Dark Theme ---- */
.dark {
--color-primary: #60A5FA;
--color-primary-dark: #3B82F6;
--color-primary-light: #93C5FD;
--color-accent: #93C5FD;
--color-pass: #34D399;
--color-warning: #FBBF24;
--color-fail: #F87171;
--bg-primary: #0F172A;
--bg-secondary: #1E293B;
--bg-card: #334155;
@@ -48,7 +57,7 @@
--text-muted: #64748B;
--border-color: #475569;
--border-focus: #3B82F6;
--border-focus: #60A5FA;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
@@ -252,11 +261,22 @@ textarea:focus-visible {
border-color: var(--color-primary);
}
.dark .btn-primary {
background: #3B82F6;
color: #FFFFFF;
border-color: #3B82F6;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
}
.dark .btn-primary:hover:not(:disabled) {
background: #2563EB;
border-color: #2563EB;
}
.btn-secondary {
background: transparent;
color: var(--text-secondary);
+1 -73
View File
@@ -1,6 +1,6 @@
<!-- TieMeasureFlow Navbar -->
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
x-data="{ mobileOpen: false, userDropdown: false, adminDropdown: false }">
x-data="{ mobileOpen: false, userDropdown: false }">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
@@ -15,9 +15,6 @@
alt="TieMeasureFlow"
class="h-8 w-auto">
{% endif %}
<span class="text-lg font-bold text-[var(--text-primary)] tracking-tight hidden sm:inline">
TieMeasureFlow
</span>
</a>
</div>
@@ -76,53 +73,6 @@
</a>
{% endif %}
{# Admin: dropdown #}
{% if current_user.get('is_admin') %}
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200"
:class="{ 'text-primary bg-primary-50 dark:bg-primary-900/20': open }">
<!-- Cog Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>{{ _('Admin') }}</span>
<svg class="w-3.5 h-3.5 transition-transform duration-200" :class="{ 'rotate-180': open }"
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- Admin Dropdown -->
<div x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute left-0 mt-1 w-48 rounded-lg bg-[var(--bg-card)] border border-[var(--border-color)]
shadow-lg py-1 z-50">
<a href="#" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
{{ _('Utenti') }}
</a>
<a href="#" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
</svg>
{{ _('Impostazioni') }}
</a>
</div>
</div>
{% endif %}
</div>
{% endif %}
@@ -298,28 +248,6 @@
</a>
{% endif %}
{% if current_user.get('is_admin') %}
<div class="border-t border-[var(--border-color)] my-2"></div>
<p class="px-3 py-1 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Admin') }}</p>
<a href="#"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
{{ _('Utenti') }}
</a>
<a href="#"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
</svg>
{{ _('Impostazioni') }}
</a>
{% endif %}
</div>
</div>
+10 -6
View File
@@ -164,7 +164,8 @@
<table class="tmf-table">
<thead>
<tr>
<th class="text-center">#</th>
<th class="text-center">{{ _('Data/Ora') }}</th>
<th>{{ _('Task') }}</th>
<th>{{ _('Descrizione') }}</th>
<th class="text-right">{{ _('Nominale') }}</th>
<th class="text-right">{{ _('Valore') }}</th>
@@ -174,10 +175,13 @@
</tr>
</thead>
<tbody>
{% for m in measurements|sort(attribute='subtask.marker_number') %}
{% for m in measurements|sort(attribute='task_info.order_index,subtask.marker_number') %}
<tr>
<td class="text-center font-mono font-medium" style="color: var(--text-primary);">
{{ m.subtask.marker_number }}
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
</td>
<td style="color: var(--text-secondary);" class="text-sm">
{{ m.task_info.title if m.task_info else '-' }}
</td>
<td style="color: var(--text-primary);">
{{ m.subtask.description }}
@@ -203,14 +207,14 @@
{% endif %}
</td>
<td class="text-center">
{% if m.method == 'manual' %}
{% if m.input_method == 'manual' %}
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
</svg>
<span class="text-sm">{{ _('Manuale') }}</span>
</div>
{% elif m.method == 'caliper' %}
{% elif m.input_method == 'usb_caliper' %}
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
+56 -20
View File
@@ -54,6 +54,7 @@
{% block content %}
<script>
window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }};
window.__allTaskIds = {{ all_task_ids|tojson }};
</script>
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col"
x-data="taskExecute()"
@@ -210,23 +211,43 @@
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs font-medium transition-all duration-200"
:class="idx === currentIndex
? 'bg-primary text-white border-primary shadow-md marker-active-pulse'
: isMeasured(st.id)
: getMeasurementStatus(st.id) === 'pass'
? 'bg-measure-pass/10 text-measure-pass border-measure-pass/30 hover:bg-measure-pass/20'
: 'bg-[var(--bg-card)] text-[var(--text-secondary)] border-[var(--border-color)] hover:border-primary/50 hover:text-primary'
: getMeasurementStatus(st.id) === 'fail'
? 'bg-red-50 dark:bg-red-900/20 text-measure-fail border-measure-fail/30 hover:bg-red-100 dark:hover:bg-red-900/30'
: getMeasurementStatus(st.id) === 'warning'
? 'bg-amber-50 dark:bg-amber-900/20 text-measure-warning border-measure-warning/30 hover:bg-amber-100 dark:hover:bg-amber-900/30'
: 'bg-[var(--bg-card)] text-[var(--text-secondary)] border-[var(--border-color)] hover:border-primary/50 hover:text-primary'
">
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold"
:class="idx === currentIndex
? 'bg-white/20'
: isMeasured(st.id)
: getMeasurementStatus(st.id) === 'pass'
? 'bg-measure-pass/20'
: 'bg-[var(--bg-secondary)]'
: getMeasurementStatus(st.id) === 'fail'
? 'bg-measure-fail/20'
: getMeasurementStatus(st.id) === 'warning'
? 'bg-measure-warning/20'
: 'bg-[var(--bg-secondary)]'
"
x-text="st.marker_number"></span>
<span x-text="st.description" class="truncate max-w-[100px]"></span>
{# Check icon if measured #}
<svg x-show="isMeasured(st.id)" class="w-3.5 h-3.5 text-measure-pass shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{# Status icon: pass=check, fail=X, warning=! #}
<template x-if="getMeasurementStatus(st.id) === 'pass'">
<svg class="w-3.5 h-3.5 text-measure-pass shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
</template>
<template x-if="getMeasurementStatus(st.id) === 'fail'">
<svg class="w-3.5 h-3.5 text-measure-fail shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</template>
<template x-if="getMeasurementStatus(st.id) === 'warning'">
<svg class="w-3.5 h-3.5 text-measure-warning shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
</template>
</button>
</template>
</div>
@@ -628,6 +649,11 @@ function taskExecute() {
return m ? m.value : null;
},
getMeasurementStatus(subtaskId) {
const m = this.measurements.find(m => m.subtask_id === subtaskId);
return m ? m.pass_fail : null;
},
// ---- Handle numpad confirm ----
async handleMeasurement(value, inputMethod) {
if (!this.currentSubtask || this.saving) return;
@@ -652,10 +678,8 @@ function taskExecute() {
},
body: JSON.stringify({
subtask_id: this.currentSubtask.id,
task_id: this.task.id,
version_id: this.task.version_id,
value: value,
pass_fail: pf,
deviation: dev,
lot_number: this.lotNumber,
serial_number: this.serialNumber,
input_method: inputMethod || 'manual',
@@ -670,20 +694,32 @@ function taskExecute() {
return;
}
// Record measurement locally
this.measurements.push({
subtask_id: this.currentSubtask.id,
value: value,
pass_fail: pf,
deviation: dev,
});
// Record measurement locally (replace if already measured)
const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id);
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: pf, deviation: dev };
if (existingIdx !== -1) {
this.measurements.splice(existingIdx, 1, mEntry);
} else {
this.measurements.push(mEntry);
}
this.saving = false;
// Pause 1s to let user see the result (pass/fail/warning)
await new Promise(r => setTimeout(r, 1000));
// Check if all done
if (this.completedCount >= this.totalSubtasks) {
// Show completion overlay
this.showCompletionOverlay = true;
// Auto-advance to next task, or show overlay on last task
const taskIds = window.__allTaskIds || [];
const currentIdx = taskIds.indexOf(this.task.id);
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
// Navigate to next task
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
} else {
// Last task (or unknown): show completion overlay
this.showCompletionOverlay = true;
}
return;
}
+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,