Maker: duplica ricetta e durata loop predefinita (#52, #49)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 12:26:49 +02:00
parent 3c1d5f1323
commit 6648882ec1
4 changed files with 176 additions and 2 deletions
+29 -1
View File
@@ -57,7 +57,20 @@ def recipe_list():
@role_required("Maker") @role_required("Maker")
def recipe_new(): def recipe_new():
"""Create new recipe.""" """Create new recipe."""
return render_template("maker/recipe_editor.html", recipe=None) # #49: loop duration pre-filled from the system default (new recipes only);
# missing/empty/invalid key -> None -> behaviour unchanged
default_interval = None
settings = api_client.get("/api/settings")
if isinstance(settings, dict) and not settings.get("error"):
try:
value = int(str(settings.get("default_measurement_interval_minutes", "")).strip())
if 1 <= value <= 1440:
default_interval = value
except ValueError:
pass
return render_template(
"maker/recipe_editor.html", recipe=None, default_interval=default_interval
)
@maker_bp.route("/recipes/<int:recipe_id>/edit") @maker_bp.route("/recipes/<int:recipe_id>/edit")
@@ -201,6 +214,21 @@ def api_create_recipe():
return jsonify(resp), 201 return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>/duplicate", methods=["POST"])
@login_required
@role_required("Maker")
def api_duplicate_recipe(recipe_id: int):
"""Proxy: Duplicate a recipe under a new code/name (#52)."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/recipes/{recipe_id}/duplicate", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"]) @maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
@login_required @login_required
@role_required("Maker") @role_required("Maker")
@@ -526,7 +526,8 @@ function recipeEditor() {
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }}, currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
// ---- Measurement interval ---- // ---- Measurement interval ----
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }}, // #49: a new recipe starts from the system default duration (default_interval), an existing one never does
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else (default_interval if not recipe and default_interval else 'null'))|tojson }},
// ---- Rules of measurement (points 8 and 9) ---- // ---- Rules of measurement (points 8 and 9) ----
// A new recipe forbids typing: the caliper is the instrument, and permission // A new recipe forbids typing: the caliper is the instrument, and permission
@@ -10,6 +10,51 @@
filter: 'all', filter: 'all',
deleteModal: false, deleteModal: false,
deleteTarget: null, deleteTarget: null,
// #52: duplicate modal state
dupModal: false,
dupSource: null,
dupCode: '',
dupName: '',
dupError: '',
dupBusy: false,
openDupModal(recipe) {
this.dupSource = recipe;
this.dupCode = recipe.code + '-COPIA';
this.dupName = recipe.name + ' ' + {{ _('(copia)')|tojson_attr }};
this.dupError = '';
this.dupModal = true;
},
async duplicateRecipe() {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
this.dupBusy = true;
this.dupError = '';
try {
const resp = await fetch(`/maker/api/recipes/${this.dupSource.id}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
body: JSON.stringify({ code: this.dupCode.trim(), name: this.dupName.trim() })
});
const data = await resp.json().catch(() => ({}));
if (resp.ok) {
// open the new recipe in the editor
window.location.href = '/maker/recipes/' + data.id + '/edit';
return;
}
if (resp.status === 409) {
this.dupError = {{ _('Esiste già una ricetta con questo codice')|tojson_attr }};
} else if (resp.status === 422) {
this.dupError = {{ _('Codice o nome non validi')|tojson_attr }};
} else {
this.dupError = data.detail || data.error || {{ _('Errore durante la duplicazione')|tojson_attr }};
}
} catch (err) {
this.dupError = {{ _('Errore di connessione')|tojson_attr }};
} finally {
this.dupBusy = false;
}
},
get filteredRecipes() { get filteredRecipes() {
let filtered = this.recipes; let filtered = this.recipes;
@@ -265,6 +310,15 @@
{{ _('Versioni') }} {{ _('Versioni') }}
</a> </a>
<!-- Duplica (#52) -->
<button @click="openDupModal(recipe)"
class="btn btn-secondary gap-1.5 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{{ _('Duplica') }}
</button>
<!-- Spacer --> <!-- Spacer -->
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -318,6 +372,42 @@
</a> </a>
</div> </div>
<!-- Duplicate Modal (#52) -->
<div x-show="dupModal"
x-cloak
@keydown.escape.window="dupModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true">
<div @click="dupModal = false" class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex min-h-full items-center justify-center p-4">
<div class="tmf-card max-w-lg w-full relative p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">
{{ _('Duplica ricetta') }}
</h3>
<div class="mb-3">
<label class="tmf-label" for="dup-code">{{ _('Codice') }}</label>
<input id="dup-code" type="text" x-model="dupCode" maxlength="100" class="tmf-input">
</div>
<div class="mb-3">
<label class="tmf-label" for="dup-name">{{ _('Nome') }}</label>
<input id="dup-name" type="text" x-model="dupName" maxlength="255" class="tmf-input">
</div>
<p x-show="dupError" x-text="dupError" class="text-sm text-red-600 mb-3"></p>
<div class="flex flex-col sm:flex-row gap-3">
<button @click="dupModal = false" class="btn btn-secondary flex-1 justify-center">
{{ _('Annulla') }}
</button>
<button @click="duplicateRecipe()"
:disabled="dupBusy || !dupCode.trim() || !dupName.trim()"
class="btn btn-primary flex-1 justify-center">
{{ _('Duplica') }}
</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div x-show="deleteModal" <div x-show="deleteModal"
x-cloak x-cloak
@@ -71,6 +71,61 @@ class TestRecipeNew:
assert resp.status_code == 200 assert resp.status_code == 200
def test_recipe_new_default_interval_present(self, logged_in_client, mock_api_client):
"""#49: default_measurement_interval_minutes pre-fills the loop duration."""
mock_api_client.get.return_value = {"default_measurement_interval_minutes": "45"}
html = logged_in_client.get("/maker/recipes/new").get_data(as_text=True)
assert "measurementIntervalMinutes: 45," in html
def test_recipe_new_default_interval_absent(self, logged_in_client, mock_api_client):
"""#49: key missing, empty or invalid -> unchanged behaviour (null)."""
for payload in ({}, {"default_measurement_interval_minutes": ""},
{"default_measurement_interval_minutes": "abc"},
{"default_measurement_interval_minutes": "5000"}):
mock_api_client.get.return_value = payload
html = logged_in_client.get("/maker/recipes/new").get_data(as_text=True)
assert 'measurementIntervalMinutes: "null",' in html
def test_recipe_edit_ignores_default_interval(self, logged_in_client, mock_api_client):
"""#49: an existing recipe never receives the default."""
def fake_get(path, *a, **k):
if path == "/api/recipes/7":
return {"id": 7, "code": "R", "name": "R"}
if path.endswith("/versions"):
return []
return {"default_measurement_interval_minutes": "45"}
mock_api_client.get.side_effect = fake_get
html = logged_in_client.get("/maker/recipes/7/edit").get_data(as_text=True)
assert 'measurementIntervalMinutes: "null",' in html
class TestRecipeDuplicateProxy:
"""POST /maker/api/recipes/<id>/duplicate (#52)."""
def test_duplicate_proxy_ok(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"id": 9, "code": "A-COPIA"}
resp = logged_in_client.post(
"/maker/api/recipes/3/duplicate", json={"code": "A-COPIA", "name": "A (copia)"}
)
assert resp.status_code == 201
assert resp.get_json()["id"] == 9
mock_api_client.post.assert_called_with(
"/api/recipes/3/duplicate", data={"code": "A-COPIA", "name": "A (copia)"}
)
def test_duplicate_proxy_conflict(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 409, "detail": "exists"}
resp = logged_in_client.post(
"/maker/api/recipes/3/duplicate", json={"code": "A", "name": "A"}
)
assert resp.status_code == 409
def test_recipe_list_has_duplicate_button(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/maker/recipes").get_data(as_text=True)
assert "openDupModal" in html
class TestRoleRequired: class TestRoleRequired:
"""Maker role enforcement tests.""" """Maker role enforcement tests."""