diff --git a/src/frontend/flask_app/templates/maker/task_editor.html b/src/frontend/flask_app/templates/maker/task_editor.html index 99d94e5..1ed954d 100644 --- a/src/frontend/flask_app/templates/maker/task_editor.html +++ b/src/frontend/flask_app/templates/maker/task_editor.html @@ -1156,6 +1156,15 @@ function taskEditor() { // Sort tasks by order_index on load this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0)); + // Show the notice left by _reloadForNewVersion() before its reload (gitea#4). + try { + var versionNotice = sessionStorage.getItem('taskEditorVersionNotice'); + if (versionNotice) { + sessionStorage.removeItem('taskEditorVersionNotice'); + this.successMessage = versionNotice; + } + } catch (_e) { /* sessionStorage unavailable - notice just does not show */ } + // Check if returning from task_drawing with updated annotations try { var updatedRaw = sessionStorage.getItem('taskDrawingUpdated'); @@ -1193,6 +1202,27 @@ function taskEditor() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }, + // ============================================================ + // Version conflict (gitea#4) + // + // Copy-on-write on a version with measurements creates NEW ids for its + // task/subtasks. The first save after that happens silently (200/201, + // new ids in the response); a save that still targets an id from before + // gets 409 "Cannot modify a non-current version". Both cases are fixed + // the same way: reload, so the editor starts fresh from the current + // version instead of patching a local state that copy-on-write already + // moved out from under it. + // ============================================================ + _reloadForNewVersion() { + try { + sessionStorage.setItem( + 'taskEditorVersionNotice', + {{ _("La ricetta ha una nuova versione: pagina ricaricata")|tojson }} + ); + } catch (_e) { /* sessionStorage unavailable - reload still happens */ } + window.location.reload(); + }, + // ============================================================ // Render Task Preview (deferred) // ============================================================ @@ -1341,6 +1371,7 @@ function taskEditor() { }) }); + if (resp.status === 409) { this._reloadForNewVersion(); return; } const data = await resp.json(); if (data.error) { @@ -1399,10 +1430,16 @@ function taskEditor() { }) }); + if (resp.status === 409) { this._reloadForNewVersion(); return; } const data = await resp.json(); if (data.error) { this.errorMessage = data.detail || {{ _("Errore nel salvataggio del task")|tojson }}; + } else if (data.id !== taskId) { + // Copy-on-write happened silently: this task, and every other one + // in the recipe, now has a different id than what is in local state. + this._reloadForNewVersion(); + return; } else { // Update local state const idx = this.tasks.findIndex(t => t.id === taskId); @@ -1440,6 +1477,7 @@ function taskEditor() { headers: { 'X-CSRFToken': this.csrfToken() } }); + if (resp.status === 409) { this._reloadForNewVersion(); return; } if (resp.ok || resp.status === 204) { this.tasks = this.tasks.filter(t => t.id !== taskId); this.deleteTaskModal = false; @@ -1540,6 +1578,7 @@ function taskEditor() { body: JSON.stringify({ task_ids: taskIds }) }); + if (resp.status === 409) { this._reloadForNewVersion(); return; } const data = await resp.json(); if (data.error) { @@ -1608,10 +1647,16 @@ function taskEditor() { body: JSON.stringify(payload) }); + if (resp.status === 409) { this._reloadForNewVersion(); return; } const data = await resp.json(); if (data.error) { this.errorMessage = data.detail || {{ _("Errore nella creazione della misurazione")|tojson }}; + } else if (data.task_id !== taskId) { + // Copy-on-write happened silently: this task, and every other one + // in the recipe, now has a different id than what is in local state. + this._reloadForNewVersion(); + return; } else { const task = this.tasks.find(t => t.id === taskId); if (task) { @@ -1679,10 +1724,16 @@ function taskEditor() { body: JSON.stringify(payload) }); + if (resp.status === 409) { this._reloadForNewVersion(); return; } const data = await resp.json(); if (data.error) { this.errorMessage = data.detail || {{ _("Errore nel salvataggio della misurazione")|tojson }}; + } else if (data.task_id !== taskId) { + // Copy-on-write happened silently: this task, and every other one + // in the recipe, now has a different id than what is in local state. + this._reloadForNewVersion(); + return; } else { // Update local state const task = this.tasks.find(t => t.id === taskId); @@ -1719,6 +1770,7 @@ function taskEditor() { headers: { 'X-CSRFToken': this.csrfToken() } }); + if (resp.status === 409) { this._reloadForNewVersion(); return; } if (resp.ok || resp.status === 204) { const task = this.tasks.find(t => t.id === taskId); if (task && task.subtasks) { @@ -1796,6 +1848,7 @@ function taskEditor() { }) }); + if (updateResp.status === 409) { this._reloadForNewVersion(); return; } const updateData = await updateResp.json(); if (updateData.error) { this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }}; diff --git a/src/frontend/flask_app/tests/test_maker.py b/src/frontend/flask_app/tests/test_maker.py index fe58526..15e18a1 100644 --- a/src/frontend/flask_app/tests/test_maker.py +++ b/src/frontend/flask_app/tests/test_maker.py @@ -121,3 +121,43 @@ class TestRecipeEdit: resp = logged_in_client.get("/maker/recipes/999/edit", follow_redirects=False) assert resp.status_code == 302 assert "/maker/recipes" in resp.headers["Location"] + + +class TestVersionConflictProxy: + """gitea#4: a 409 from the backend (edit targeting a non-current version, + left behind by an earlier copy-on-write) must reach the browser as a 409 - + task_editor.html's _reloadForNewVersion() only triggers on that status. + """ + + _CONFLICT = { + "error": True, + "status_code": 409, + "detail": "Cannot modify a non-current version. Create a new version first.", + } + + def test_update_task_proxies_409(self, logged_in_client, mock_api_client): + mock_api_client.put.return_value = self._CONFLICT + + resp = logged_in_client.put("/maker/api/tasks/11", json={"title": "x"}) + + assert resp.status_code == 409 + assert resp.get_json()["detail"] == self._CONFLICT["detail"] + + def test_update_subtask_proxies_409(self, logged_in_client, mock_api_client): + mock_api_client.put.return_value = self._CONFLICT + + resp = logged_in_client.put("/maker/api/subtasks/21", json={"nominal": 1.0}) + + assert resp.status_code == 409 + assert resp.get_json()["detail"] == self._CONFLICT["detail"] + + def test_create_subtask_proxies_409(self, logged_in_client, mock_api_client): + mock_api_client.post.return_value = self._CONFLICT + + resp = logged_in_client.post( + "/maker/api/tasks/11/subtasks", + json={"marker_number": 1, "description": "x"}, + ) + + assert resp.status_code == 409 + assert resp.get_json()["detail"] == self._CONFLICT["detail"] diff --git a/src/frontend/flask_app/tests/test_template_js_syntax.py b/src/frontend/flask_app/tests/test_template_js_syntax.py index 80c624b..c45c78a 100644 --- a/src/frontend/flask_app/tests/test_template_js_syntax.py +++ b/src/frontend/flask_app/tests/test_template_js_syntax.py @@ -395,3 +395,45 @@ def test_task_list_inline_js_is_valid(logged_in_client, mock_measure_api): _node_check(body, f"/measure/tasks script[{i}]") _check_alpine_attributes(html, "/measure/tasks") + + +@pytest.fixture +def mock_maker_api(): + """Patch api_client used inside the maker blueprint.""" + mock = MagicMock() + with patch("blueprints.maker.api_client", mock): + yield mock + + +def test_task_editor_inline_js_is_valid(logged_in_client, mock_maker_api): + """gitea#4: the version-conflict reload added _reloadForNewVersion() and + new `if (…status === 409)` / `data.task_id !== taskId` checks in every + save handler. Regression guard that the inline script still parses.""" + _force_italian(logged_in_client) + mock_maker_api.get.side_effect = api_get_router({ + "/api/recipes/3/tasks": [{ + "id": 11, "order_index": 0, "title": "Quota d'ingresso", + "task_type": "measure", "file_path": None, "file_type": None, + "annotations_json": None, + "subtasks": [{ + "id": 21, "marker_number": 1, "description": "Diametro", + "nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5, + "unit": "mm", + }], + }], + "/api/recipes/3": { + "id": 3, "code": "REC-3", "name": "Ricetta con l'apostrofo", + "description": "Con l'apostrofo anche qui", + }, + }) + + resp = logged_in_client.get("/maker/recipes/3/tasks") + assert resp.status_code == 200 + html = resp.get_data(as_text=True) + + scripts = _INLINE_SCRIPT_RX.findall(html) + assert scripts, "expected at least one inline