diff --git a/src/frontend/flask_app/blueprints/maker.py b/src/frontend/flask_app/blueprints/maker.py index 0080ee4..4eed9c3 100644 --- a/src/frontend/flask_app/blueprints/maker.py +++ b/src/frontend/flask_app/blueprints/maker.py @@ -57,7 +57,20 @@ def recipe_list(): @role_required("Maker") def recipe_new(): """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//edit") @@ -201,6 +214,21 @@ def api_create_recipe(): return jsonify(resp), 201 +@maker_bp.route("/api/recipes//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/", methods=["PUT"]) @login_required @role_required("Maker") diff --git a/src/frontend/flask_app/templates/maker/recipe_editor.html b/src/frontend/flask_app/templates/maker/recipe_editor.html index a4a58fb..69ce059 100644 --- a/src/frontend/flask_app/templates/maker/recipe_editor.html +++ b/src/frontend/flask_app/templates/maker/recipe_editor.html @@ -526,7 +526,8 @@ function recipeEditor() { currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }}, // ---- 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) ---- // A new recipe forbids typing: the caliper is the instrument, and permission diff --git a/src/frontend/flask_app/templates/maker/recipe_list.html b/src/frontend/flask_app/templates/maker/recipe_list.html index 339b9e3..0409ed9 100644 --- a/src/frontend/flask_app/templates/maker/recipe_list.html +++ b/src/frontend/flask_app/templates/maker/recipe_list.html @@ -10,6 +10,51 @@ filter: 'all', deleteModal: false, 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() { let filtered = this.recipes; @@ -265,6 +310,15 @@ {{ _('Versioni') }} + + +
@@ -318,6 +372,42 @@ + + +
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//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: """Maker role enforcement tests."""