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.orm.user import User
|
||||||
from src.backend.models.api.recipe import (
|
from src.backend.models.api.recipe import (
|
||||||
RecipeCreate,
|
RecipeCreate,
|
||||||
|
RecipeDuplicate,
|
||||||
RecipeListResponse,
|
RecipeListResponse,
|
||||||
RecipeResponse,
|
RecipeResponse,
|
||||||
RecipeUpdate,
|
RecipeUpdate,
|
||||||
@@ -52,6 +53,18 @@ async def create_recipe(
|
|||||||
return _recipe_to_response(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)
|
@router.get("", response_model=RecipeListResponse)
|
||||||
async def list_recipes(
|
async def list_recipes(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ class RecipeCreate(BaseModel):
|
|||||||
annotations_json: Optional[dict] = None
|
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):
|
class RecipeUpdate(BaseModel):
|
||||||
"""Schema for updating a recipe (creates new version)."""
|
"""Schema for updating a recipe (creates new version)."""
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
|||||||
@@ -255,6 +255,78 @@ async def create_recipe(
|
|||||||
return result.scalar_one()
|
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(
|
async def create_new_version(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
recipe_id: int,
|
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
|
||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user