Merge lavoro/daitarn-trafilo-duplica
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user