diff --git a/src/backend/api/routers/recipes.py b/src/backend/api/routers/recipes.py index 9ce7d87..4e8a99f 100644 --- a/src/backend/api/routers/recipes.py +++ b/src/backend/api/routers/recipes.py @@ -9,6 +9,7 @@ from src.backend.api.middleware.api_key import get_current_user, require_maker, from src.backend.models.orm.user import User from src.backend.models.api.recipe import ( RecipeCreate, + RecipeDuplicate, RecipeListResponse, RecipeResponse, RecipeUpdate, @@ -52,6 +53,18 @@ async def create_recipe( return _recipe_to_response(recipe) +@router.post("/{recipe_id}/duplicate", response_model=RecipeResponse, status_code=201) +async def duplicate_recipe( + recipe_id: int, + data: RecipeDuplicate, + user: User = Depends(require_maker), + db: AsyncSession = Depends(get_db), +): + """Duplicate a recipe with its current tasks under a new code (#52). Requires Maker role.""" + recipe = await recipe_service.duplicate_recipe(db, recipe_id, data.code, data.name, user) + return _recipe_to_response(recipe) + + @router.get("", response_model=RecipeListResponse) async def list_recipes( page: int = Query(1, ge=1), diff --git a/src/backend/models/api/recipe.py b/src/backend/models/api/recipe.py index 07a7888..fdb0b45 100644 --- a/src/backend/models/api/recipe.py +++ b/src/backend/models/api/recipe.py @@ -26,6 +26,12 @@ class RecipeCreate(BaseModel): annotations_json: Optional[dict] = None +class RecipeDuplicate(BaseModel): + """Schema for duplicating a recipe (#52): only the new code and name.""" + code: str = Field(..., min_length=1, max_length=100) + name: str = Field(..., min_length=1, max_length=255) + + class RecipeUpdate(BaseModel): """Schema for updating a recipe (creates new version).""" name: Optional[str] = Field(None, min_length=1, max_length=255) diff --git a/src/backend/services/recipe_service.py b/src/backend/services/recipe_service.py index f7d1789..7a2c3d5 100644 --- a/src/backend/services/recipe_service.py +++ b/src/backend/services/recipe_service.py @@ -255,6 +255,78 @@ async def create_recipe( return result.scalar_one() +async def duplicate_recipe( + db: AsyncSession, + recipe_id: int, + new_code: str, + new_name: str, + user: User, +) -> Recipe: + """Duplicate a recipe (#52): new code/name, v1 with the current version's tasks. + + The source recipe is left untouched. + """ + source = await _get_recipe_or_404(db, recipe_id) + + existing = await db.execute(select(Recipe).where(Recipe.code == new_code)) + if existing.scalar_one_or_none() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Recipe code '{new_code}' already exists", + ) + + # Same header fields create_recipe persists, taken from the source recipe + recipe = Recipe( + code=new_code, + name=new_name, + description=source.description, + image_path=source.image_path, + measurement_interval_minutes=source.measurement_interval_minutes, + requires_lot=source.requires_lot, + requires_serial=source.requires_serial, + allow_manual_input=source.allow_manual_input, + created_by=user.id, + ) + db.add(recipe) + await db.flush() + + version = RecipeVersion( + recipe_id=recipe.id, + version_number=1, + is_current=True, + created_by=user.id, + change_notes=f"Duplicated from {source.code}", + ) + db.add(version) + await db.flush() + + # Reuse the existing deep copy of tasks/subtasks from the current version + source_version = await _get_current_version(db, source.id) + if source_version is not None: + await _copy_tasks_to_version(db, source_version, version) + + await _write_audit( + db, + recipe_id=recipe.id, + old_version_id=None, + new_version_id=version.id, + changed_by=user.id, + change_type="CREATE", + change_reason=f"Duplicated from recipe {source.code}", + ) + + result = await db.execute( + select(Recipe) + .where(Recipe.id == recipe.id) + .options( + selectinload(Recipe.versions) + .selectinload(RecipeVersion.tasks) + .selectinload(RecipeTask.subtasks) + ) + ) + return result.scalar_one() + + async def create_new_version( db: AsyncSession, recipe_id: int, diff --git a/src/backend/tests/test_recipe_duplicate.py b/src/backend/tests/test_recipe_duplicate.py new file mode 100644 index 0000000..75bd7ed --- /dev/null +++ b/src/backend/tests/test_recipe_duplicate.py @@ -0,0 +1,74 @@ +"""Tests for POST /api/recipes/{id}/duplicate (#52).""" +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.backend.models.orm.recipe import Recipe, RecipeVersion +from src.backend.models.orm.task import RecipeSubtask, RecipeTask +from src.backend.models.orm.user import User +from src.backend.tests.conftest import auth_headers, create_test_recipe + + +class TestDuplicateRecipe: + async def test_duplicate_copies_tasks_and_keeps_original( + self, client: AsyncClient, maker_user: User, db_session: AsyncSession + ): + src = await create_test_recipe(db_session, maker_user.id) + src_id = src.id + before_tasks = (await db_session.execute(select(func.count(RecipeTask.id)))).scalar_one() + before_subs = (await db_session.execute(select(func.count(RecipeSubtask.id)))).scalar_one() + + resp = await client.post( + f"/api/recipes/{src_id}/duplicate", + json={"code": "REC-COPIA", "name": "Copia"}, + headers=auth_headers(maker_user), + ) + assert resp.status_code == 201 + data = resp.json() + assert data["code"] == "REC-COPIA" and data["name"] == "Copia" + assert data["id"] != src_id + assert data["description"] == "A recipe for testing" + assert data["current_version"]["version_number"] == 1 + + # Tasks and subtasks doubled: copied, not moved + after_tasks = (await db_session.execute(select(func.count(RecipeTask.id)))).scalar_one() + after_subs = (await db_session.execute(select(func.count(RecipeSubtask.id)))).scalar_one() + assert after_tasks == before_tasks * 2 + assert after_subs == before_subs * 2 + + new_version = (await db_session.execute( + select(RecipeVersion).where(RecipeVersion.recipe_id == data["id"]) + )).scalar_one() + new_tasks = (await db_session.execute( + select(RecipeTask).where(RecipeTask.version_id == new_version.id) + )).scalars().all() + assert len(new_tasks) == before_tasks + + # Original unchanged + orig = (await db_session.execute(select(Recipe).where(Recipe.id == src_id))).scalar_one() + assert orig.code == "REC-001" and orig.name == "Test Recipe" + orig_versions = (await db_session.execute( + select(func.count(RecipeVersion.id)).where(RecipeVersion.recipe_id == src_id) + )).scalar_one() + assert orig_versions == 1 + + async def test_duplicate_code_conflict( + self, client: AsyncClient, maker_user: User, db_session: AsyncSession + ): + src = await create_test_recipe(db_session, maker_user.id) + resp = await client.post( + f"/api/recipes/{src.id}/duplicate", + json={"code": "REC-001", "name": "X"}, + headers=auth_headers(maker_user), + ) + assert resp.status_code == 409 + + async def test_duplicate_missing_recipe( + self, client: AsyncClient, maker_user: User + ): + resp = await client.post( + "/api/recipes/99999/duplicate", + json={"code": "NEW", "name": "X"}, + headers=auth_headers(maker_user), + ) + assert resp.status_code == 404 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."""