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 7faaeb1..3c252f6 100644 --- a/src/backend/services/recipe_service.py +++ b/src/backend/services/recipe_service.py @@ -253,6 +253,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