fix(maker): l'editor task si riallinea al copy-on-write invece di 409 (#4)
Dopo il copy-on-write del backend (modifica su versione con misure -> nuovi id per task e quote), task_editor.html teneva in memoria gli id vecchi: il salvataggio successivo riceveva 409 "Cannot modify a non-current version". Ogni handler di salvataggio (create/update/delete di task e quote, reorder, upload immagine) ora ricarica la pagina su 409, mostrando un avviso dopo il reload. updateTask/updateSubtask/ addSubtask ricaricano anche su successo se l'id restituito (recipe_id task, task_id quota) non corrisponde a quello locale - il copy-on-write avvenuto silenziosamente. Stringa aggiunta ai due cataloghi .po. Test: proxy Flask che verifica il passaggio del 409 (test_maker.py), sintassi JS dello script inline di task_editor.html con node --check (test_template_js_syntax.py). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1156,6 +1156,15 @@ function taskEditor() {
|
|||||||
// Sort tasks by order_index on load
|
// Sort tasks by order_index on load
|
||||||
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
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
|
// Check if returning from task_drawing with updated annotations
|
||||||
try {
|
try {
|
||||||
var updatedRaw = sessionStorage.getItem('taskDrawingUpdated');
|
var updatedRaw = sessionStorage.getItem('taskDrawingUpdated');
|
||||||
@@ -1193,6 +1202,27 @@ function taskEditor() {
|
|||||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
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)
|
// Render Task Preview (deferred)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1341,6 +1371,7 @@ function taskEditor() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
@@ -1399,10 +1430,16 @@ function taskEditor() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.errorMessage = data.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
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 {
|
} else {
|
||||||
// Update local state
|
// Update local state
|
||||||
const idx = this.tasks.findIndex(t => t.id === taskId);
|
const idx = this.tasks.findIndex(t => t.id === taskId);
|
||||||
@@ -1440,6 +1477,7 @@ function taskEditor() {
|
|||||||
headers: { 'X-CSRFToken': this.csrfToken() }
|
headers: { 'X-CSRFToken': this.csrfToken() }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
||||||
if (resp.ok || resp.status === 204) {
|
if (resp.ok || resp.status === 204) {
|
||||||
this.tasks = this.tasks.filter(t => t.id !== taskId);
|
this.tasks = this.tasks.filter(t => t.id !== taskId);
|
||||||
this.deleteTaskModal = false;
|
this.deleteTaskModal = false;
|
||||||
@@ -1540,6 +1578,7 @@ function taskEditor() {
|
|||||||
body: JSON.stringify({ task_ids: taskIds })
|
body: JSON.stringify({ task_ids: taskIds })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
@@ -1608,10 +1647,16 @@ function taskEditor() {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.errorMessage = data.detail || {{ _("Errore nella creazione della misurazione")|tojson }};
|
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 {
|
} else {
|
||||||
const task = this.tasks.find(t => t.id === taskId);
|
const task = this.tasks.find(t => t.id === taskId);
|
||||||
if (task) {
|
if (task) {
|
||||||
@@ -1679,10 +1724,16 @@ function taskEditor() {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.errorMessage = data.detail || {{ _("Errore nel salvataggio della misurazione")|tojson }};
|
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 {
|
} else {
|
||||||
// Update local state
|
// Update local state
|
||||||
const task = this.tasks.find(t => t.id === taskId);
|
const task = this.tasks.find(t => t.id === taskId);
|
||||||
@@ -1719,6 +1770,7 @@ function taskEditor() {
|
|||||||
headers: { 'X-CSRFToken': this.csrfToken() }
|
headers: { 'X-CSRFToken': this.csrfToken() }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
||||||
if (resp.ok || resp.status === 204) {
|
if (resp.ok || resp.status === 204) {
|
||||||
const task = this.tasks.find(t => t.id === taskId);
|
const task = this.tasks.find(t => t.id === taskId);
|
||||||
if (task && task.subtasks) {
|
if (task && task.subtasks) {
|
||||||
@@ -1796,6 +1848,7 @@ function taskEditor() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updateResp.status === 409) { this._reloadForNewVersion(); return; }
|
||||||
const updateData = await updateResp.json();
|
const updateData = await updateResp.json();
|
||||||
if (updateData.error) {
|
if (updateData.error) {
|
||||||
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
||||||
|
|||||||
@@ -121,3 +121,43 @@ class TestRecipeEdit:
|
|||||||
resp = logged_in_client.get("/maker/recipes/999/edit", follow_redirects=False)
|
resp = logged_in_client.get("/maker/recipes/999/edit", follow_redirects=False)
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
assert "/maker/recipes" in resp.headers["Location"]
|
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"]
|
||||||
|
|||||||
@@ -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}]")
|
_node_check(body, f"/measure/tasks script[{i}]")
|
||||||
|
|
||||||
_check_alpine_attributes(html, "/measure/tasks")
|
_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 <script> on /maker/recipes/3/tasks"
|
||||||
|
for i, body in enumerate(scripts):
|
||||||
|
_node_check(body, f"/maker/recipes/3/tasks script[{i}]")
|
||||||
|
|
||||||
|
_check_alpine_attributes(html, "/maker/recipes/3/tasks")
|
||||||
|
|||||||
@@ -2772,3 +2772,7 @@ msgstr "Recipe not assigned to this station"
|
|||||||
msgid "Codice non valido"
|
msgid "Codice non valido"
|
||||||
msgstr "Invalid code"
|
msgstr "Invalid code"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html:1220
|
||||||
|
msgid "La ricetta ha una nuova versione: pagina ricaricata"
|
||||||
|
msgstr "The recipe has a new version: page reloaded"
|
||||||
|
|
||||||
|
|||||||
@@ -2760,3 +2760,7 @@ msgstr "Ricetta non assegnata a questa stazione"
|
|||||||
msgid "Codice non valido"
|
msgid "Codice non valido"
|
||||||
msgstr "Codice non valido"
|
msgstr "Codice non valido"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html:1220
|
||||||
|
msgid "La ricetta ha una nuova versione: pagina ricaricata"
|
||||||
|
msgstr "La ricetta ha una nuova versione: pagina ricaricata"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user