feat: duplicate recipes (#1750)

* feature/frontend: Add duplicate button to recipe

* feature/backend: Add recipe duplication endpoint

* feature/frontend: add duplication API call

* Regenerate API docs

* Fix linter errors

* Fix backend linter error

* Move recipe duplication logic to recipe service

* Add test for recipe duplication

* Improve recipe ingredients copy test

* generate types

* import type

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
Philipp 2022-12-01 06:57:26 +01:00 committed by GitHub
parent e73a72959c
commit 33dffccaa5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 258 additions and 25 deletions

File diff suppressed because one or more lines are too long

View File

@ -54,6 +54,7 @@
delete: false, delete: false,
edit: false, edit: false,
download: true, download: true,
duplicate: true,
mealplanner: true, mealplanner: true,
shoppingList: true, shoppingList: true,
print: true, print: true,

View File

@ -13,6 +13,23 @@
{{ $t("recipe.delete-confirmation") }} {{ $t("recipe.delete-confirmation") }}
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
<BaseDialog
v-model="recipeDuplicateDialog"
:title="$t('recipe.duplicate')"
color="primary"
:icon="$globals.icons.duplicate"
@confirm="duplicateRecipe()"
>
<v-card-text>
<v-text-field
v-model="recipeName"
dense
:label="$t('recipe.recipe-name')"
autofocus
@keyup.enter="duplicateRecipe()"
></v-text-field>
</v-card-text>
</BaseDialog>
<BaseDialog <BaseDialog
v-model="mealplannerDialog" v-model="mealplannerDialog"
:title="$t('recipe.add-recipe-to-mealplan')" :title="$t('recipe.add-recipe-to-mealplan')"
@ -136,6 +153,7 @@ export default defineComponent({
delete: true, delete: true,
edit: true, edit: true,
download: true, download: true,
duplicate: false,
mealplanner: true, mealplanner: true,
shoppingList: true, shoppingList: true,
print: true, print: true,
@ -199,6 +217,8 @@ export default defineComponent({
recipeDeleteDialog: false, recipeDeleteDialog: false,
mealplannerDialog: false, mealplannerDialog: false,
shoppingListDialog: false, shoppingListDialog: false,
recipeDuplicateDialog: false,
recipeName: props.name,
loading: false, loading: false,
menuItems: [] as ContextMenuItem[], menuItems: [] as ContextMenuItem[],
newMealdate: "", newMealdate: "",
@ -230,6 +250,12 @@ export default defineComponent({
color: undefined, color: undefined,
event: "download", event: "download",
}, },
duplicate: {
title: i18n.tc("general.duplicate"),
icon: $globals.icons.duplicate,
color: undefined,
event: "duplicate",
},
mealplanner: { mealplanner: {
title: i18n.tc("recipe.add-to-plan"), title: i18n.tc("recipe.add-to-plan"),
icon: $globals.icons.calendar, icon: $globals.icons.calendar,
@ -330,6 +356,13 @@ export default defineComponent({
} }
} }
async function duplicateRecipe() {
const { data } = await api.recipes.duplicateOne(props.slug, state.recipeName);
if (data && data.slug) {
router.push(`/recipe/${data.slug}`);
}
}
const { copyText } = useCopy(); const { copyText } = useCopy();
// Note: Print is handled as an event in the parent component // Note: Print is handled as an event in the parent component
@ -339,6 +372,9 @@ export default defineComponent({
}, },
edit: () => router.push(`/recipe/${props.slug}` + "?edit=true"), edit: () => router.push(`/recipe/${props.slug}` + "?edit=true"),
download: handleDownloadEvent, download: handleDownloadEvent,
duplicate: () => {
state.recipeDuplicateDialog = true;
},
mealplanner: () => { mealplanner: () => {
state.mealplannerDialog = true; state.mealplannerDialog = true;
}, },
@ -376,6 +412,7 @@ export default defineComponent({
...toRefs(state), ...toRefs(state),
shoppingLists, shoppingLists,
addRecipeToList, addRecipeToList,
duplicateRecipe,
contextMenuEventHandler, contextMenuEventHandler,
deleteRecipe, deleteRecipe,
addRecipeToPlan, addRecipeToPlan,

View File

@ -8,7 +8,7 @@ export const LOCALES = [
{ {
name: "简体中文 (Chinese simplified)", name: "简体中文 (Chinese simplified)",
value: "zh-CN", value: "zh-CN",
progress: 57, progress: 56,
}, },
{ {
name: "Tiếng Việt (Vietnamese)", name: "Tiếng Việt (Vietnamese)",
@ -23,7 +23,7 @@ export const LOCALES = [
{ {
name: "Türkçe (Turkish)", name: "Türkçe (Turkish)",
value: "tr-TR", value: "tr-TR",
progress: 32, progress: 47,
}, },
{ {
name: "Svenska (Swedish)", name: "Svenska (Swedish)",
@ -38,12 +38,12 @@ export const LOCALES = [
{ {
name: "Slovenian", name: "Slovenian",
value: "sl-SI", value: "sl-SI",
progress: 95, progress: 94,
}, },
{ {
name: "Slovak", name: "Slovak",
value: "sk-SK", value: "sk-SK",
progress: 86, progress: 85,
}, },
{ {
name: "Pусский (Russian)", name: "Pусский (Russian)",
@ -53,7 +53,7 @@ export const LOCALES = [
{ {
name: "Română (Romanian)", name: "Română (Romanian)",
value: "ro-RO", value: "ro-RO",
progress: 4, progress: 3,
}, },
{ {
name: "Português (Portuguese)", name: "Português (Portuguese)",
@ -63,27 +63,27 @@ export const LOCALES = [
{ {
name: "Português do Brasil (Brazilian Portuguese)", name: "Português do Brasil (Brazilian Portuguese)",
value: "pt-BR", value: "pt-BR",
progress: 39, progress: 40,
}, },
{ {
name: "Polski (Polish)", name: "Polski (Polish)",
value: "pl-PL", value: "pl-PL",
progress: 88, progress: 89,
}, },
{ {
name: "Norsk (Norwegian)", name: "Norsk (Norwegian)",
value: "no-NO", value: "no-NO",
progress: 85, progress: 87,
}, },
{ {
name: "Nederlands (Dutch)", name: "Nederlands (Dutch)",
value: "nl-NL", value: "nl-NL",
progress: 91, progress: 97,
}, },
{ {
name: "Lithuanian", name: "Lithuanian",
value: "lt-LT", value: "lt-LT",
progress: 0, progress: 64,
}, },
{ {
name: "한국어 (Korean)", name: "한국어 (Korean)",
@ -98,12 +98,12 @@ export const LOCALES = [
{ {
name: "Italiano (Italian)", name: "Italiano (Italian)",
value: "it-IT", value: "it-IT",
progress: 83, progress: 82,
}, },
{ {
name: "Magyar (Hungarian)", name: "Magyar (Hungarian)",
value: "hu-HU", value: "hu-HU",
progress: 78, progress: 77,
}, },
{ {
name: "עברית (Hebrew)", name: "עברית (Hebrew)",
@ -113,7 +113,7 @@ export const LOCALES = [
{ {
name: "Français (French)", name: "Français (French)",
value: "fr-FR", value: "fr-FR",
progress: 100, progress: 99,
}, },
{ {
name: "French, Canada", name: "French, Canada",
@ -123,12 +123,12 @@ export const LOCALES = [
{ {
name: "Suomi (Finnish)", name: "Suomi (Finnish)",
value: "fi-FI", value: "fi-FI",
progress: 23, progress: 22,
}, },
{ {
name: "Español (Spanish)", name: "Español (Spanish)",
value: "es-ES", value: "es-ES",
progress: 95, progress: 94,
}, },
{ {
name: "American English", name: "American English",
@ -138,7 +138,7 @@ export const LOCALES = [
{ {
name: "British English", name: "British English",
value: "en-GB", value: "en-GB",
progress: 32, progress: 31,
}, },
{ {
name: "Ελληνικά (Greek)", name: "Ελληνικά (Greek)",
@ -148,17 +148,17 @@ export const LOCALES = [
{ {
name: "Deutsch (German)", name: "Deutsch (German)",
value: "de-DE", value: "de-DE",
progress: 100, progress: 99,
}, },
{ {
name: "Dansk (Danish)", name: "Dansk (Danish)",
value: "da-DK", value: "da-DK",
progress: 100, progress: 99,
}, },
{ {
name: "Čeština (Czech)", name: "Čeština (Czech)",
value: "cs-CZ", value: "cs-CZ",
progress: 66, progress: 89,
}, },
{ {
name: "Català (Catalan)", name: "Català (Catalan)",
@ -178,6 +178,6 @@ export const LOCALES = [
{ {
name: "Afrikaans (Afrikaans)", name: "Afrikaans (Afrikaans)",
value: "af-ZA", value: "af-ZA",
progress: 0, progress: 9,
}, },
] ]

View File

@ -75,6 +75,7 @@
"delete": "Löschen", "delete": "Löschen",
"disabled": "Deaktiviert", "disabled": "Deaktiviert",
"download": "Herunterladen", "download": "Herunterladen",
"duplicate": "Duplizieren",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"enabled": "Aktiviert", "enabled": "Aktiviert",
"exception": "Fehler", "exception": "Fehler",
@ -281,6 +282,8 @@
"description": "Beschreibung", "description": "Beschreibung",
"disable-amount": "Zutatenmenge deaktivieren", "disable-amount": "Zutatenmenge deaktivieren",
"disable-comments": "Kommentare deaktivieren", "disable-comments": "Kommentare deaktivieren",
"duplicate": "Rezept duplizieren",
"duplicate-name": "Name of the new recipe",
"edit-scale": "Maßstab ändern", "edit-scale": "Maßstab ändern",
"fat-content": "Fett", "fat-content": "Fett",
"fiber-content": "Ballaststoffe", "fiber-content": "Ballaststoffe",

View File

@ -75,6 +75,7 @@
"delete": "Delete", "delete": "Delete",
"disabled": "Disabled", "disabled": "Disabled",
"download": "Download", "download": "Download",
"duplicate": "Duplicate",
"edit": "Edit", "edit": "Edit",
"enabled": "Enabled", "enabled": "Enabled",
"exception": "Exception", "exception": "Exception",
@ -282,6 +283,8 @@
"description": "Description", "description": "Description",
"disable-amount": "Disable Ingredient Amounts", "disable-amount": "Disable Ingredient Amounts",
"disable-comments": "Disable Comments", "disable-comments": "Disable Comments",
"duplicate": "Duplicate recipe",
"duplicate-name": "Name of the new recipe",
"edit-scale": "Edit Scale", "edit-scale": "Edit Scale",
"fat-content": "Fat", "fat-content": "Fat",
"fiber-content": "Fiber", "fiber-content": "Fiber",

View File

@ -1,3 +1,4 @@
import { Recipe } from "../types/recipe";
import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated"; import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
export interface CrudAPIInterface { export interface CrudAPIInterface {
@ -20,8 +21,7 @@ export abstract class BaseAPI {
export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType> export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType>
extends BaseAPI extends BaseAPI
implements CrudAPIInterface implements CrudAPIInterface {
{
abstract baseRoute: string; abstract baseRoute: string;
abstract itemRoute(itemId: string | number): string; abstract itemRoute(itemId: string | number): string;
@ -50,4 +50,10 @@ export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType>
async deleteOne(itemId: string | number) { async deleteOne(itemId: string | number) {
return await this.requests.delete<ReadType>(this.itemRoute(itemId)); return await this.requests.delete<ReadType>(this.itemRoute(itemId));
} }
async duplicateOne(itemId: string | number, newName: string | undefined) {
return await this.requests.post<Recipe>(`${this.itemRoute(itemId)}/duplicate`, {
name: newName,
});
}
} }

View File

@ -302,6 +302,9 @@ export interface RecipeCommentUpdate {
id: string; id: string;
text: string; text: string;
} }
export interface RecipeDuplicate {
name?: string;
}
export interface RecipePaginationQuery { export interface RecipePaginationQuery {
page?: number; page?: number;
perPage?: number; perPage?: number;

View File

@ -123,6 +123,7 @@ import {
mdiText, mdiText,
mdiTextBoxOutline, mdiTextBoxOutline,
mdiChefHat, mdiChefHat,
mdiContentDuplicate,
} from "@mdi/js"; } from "@mdi/js";
export const icons = { export const icons = {
@ -173,6 +174,7 @@ export const icons = {
dotsHorizontal: mdiDotsHorizontal, dotsHorizontal: mdiDotsHorizontal,
dotsVertical: mdiDotsVertical, dotsVertical: mdiDotsVertical,
download: mdiDownload, download: mdiDownload,
duplicate: mdiContentDuplicate,
email: mdiEmail, email: mdiEmail,
externalLink: mdiLinkVariant, externalLink: mdiLinkVariant,
eye: mdiEye, eye: mdiEye,

View File

@ -243,6 +243,7 @@
delete: false, delete: false,
edit: false, edit: false,
download: true, download: true,
duplicate: false,
mealplanner: false, mealplanner: false,
print: true, print: true,
share: false, share: false,

View File

@ -17,6 +17,7 @@
"generic-updated": "{name} wurde aktualisiert", "generic-updated": "{name} wurde aktualisiert",
"generic-created-with-url": "{name} wurde erstellt, {url}", "generic-created-with-url": "{name} wurde erstellt, {url}",
"generic-updated-with-url": "{name} wurde aktualisiert, {url}", "generic-updated-with-url": "{name} wurde aktualisiert, {url}",
"generic-duplicated": "{name} wurde dupliziert",
"generic-deleted": "{name} wurde gelöscht" "generic-deleted": "{name} wurde gelöscht"
} }
} }

View File

@ -17,6 +17,7 @@
"generic-updated": "{name} was updated", "generic-updated": "{name} was updated",
"generic-created-with-url": "{name} has been created, {url}", "generic-created-with-url": "{name} has been created, {url}",
"generic-updated-with-url": "{name} has been updated, {url}", "generic-updated-with-url": "{name} has been updated, {url}",
"generic-duplicated": "{name} has been duplicated",
"generic-deleted": "{name} has been deleted" "generic-deleted": "{name} has been deleted"
} }
} }

View File

@ -31,7 +31,7 @@ from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
from mealie.schema.recipe.recipe_settings import RecipeSettings from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.schema.recipe.recipe_step import RecipeStep from mealie.schema.recipe.recipe_step import RecipeStep
from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse from mealie.schema.recipe.request_helpers import RecipeDuplicate, RecipeZipTokenResponse, UpdateImageResponse
from mealie.schema.response.responses import ErrorResponse from mealie.schema.response.responses import ErrorResponse
from mealie.services import urls from mealie.services import urls
from mealie.services.event_bus_service.event_types import ( from mealie.services.event_bus_service.event_types import (
@ -298,6 +298,26 @@ class RecipeController(BaseRecipeController):
return new_recipe.slug return new_recipe.slug
@router.post("/{slug}/duplicate", status_code=201, response_model=Recipe)
def duplicate_one(self, slug: str, req: RecipeDuplicate) -> Recipe:
"""Duplicates a recipe with a new custom name if given"""
try:
new_recipe = self.service.duplicate_one(slug, req)
except Exception as e:
self.handle_exceptions(e)
if new_recipe:
self.publish_event(
event_type=EventTypes.recipe_created,
document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=new_recipe.slug),
message=self.t(
"notifications.generic-duplicated",
name=new_recipe.name,
),
)
return new_recipe
@router.put("/{slug}") @router.put("/{slug}")
def update_one(self, slug: str, data: Recipe): def update_one(self, slug: str, data: Recipe):
"""Updates a recipe by existing slug and data.""" """Updates a recipe by existing slug and data."""

View File

@ -76,9 +76,10 @@ from .recipe_timeline_events import (
RecipeTimelineEventOut, RecipeTimelineEventOut,
RecipeTimelineEventPagination, RecipeTimelineEventPagination,
RecipeTimelineEventUpdate, RecipeTimelineEventUpdate,
TimelineEventType,
) )
from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave
from .request_helpers import RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
__all__ = [ __all__ = [
"RecipeToolCreate", "RecipeToolCreate",
@ -90,12 +91,14 @@ __all__ = [
"RecipeTimelineEventOut", "RecipeTimelineEventOut",
"RecipeTimelineEventPagination", "RecipeTimelineEventPagination",
"RecipeTimelineEventUpdate", "RecipeTimelineEventUpdate",
"TimelineEventType",
"RecipeAsset", "RecipeAsset",
"RecipeSettings", "RecipeSettings",
"RecipeShareToken", "RecipeShareToken",
"RecipeShareTokenCreate", "RecipeShareTokenCreate",
"RecipeShareTokenSave", "RecipeShareTokenSave",
"RecipeShareTokenSummary", "RecipeShareTokenSummary",
"RecipeDuplicate",
"RecipeSlug", "RecipeSlug",
"RecipeZipTokenResponse", "RecipeZipTokenResponse",
"SlugResponse", "SlugResponse",

View File

@ -20,3 +20,7 @@ class UpdateImageResponse(BaseModel):
class RecipeZipTokenResponse(BaseModel): class RecipeZipTokenResponse(BaseModel):
token: str token: str
class RecipeDuplicate(BaseModel):
name: str | None

View File

@ -3,17 +3,21 @@ import shutil
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from shutil import copytree, rmtree from shutil import copytree, rmtree
from uuid import uuid4
from zipfile import ZipFile from zipfile import ZipFile
from fastapi import UploadFile from fastapi import UploadFile
from slugify import slugify
from mealie.core import exceptions from mealie.core import exceptions
from mealie.pkgs import cache
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import CreateRecipe, Recipe from mealie.schema.recipe.recipe import CreateRecipe, Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_settings import RecipeSettings from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.schema.recipe.recipe_step import RecipeStep from mealie.schema.recipe.recipe_step import RecipeStep
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
from mealie.schema.recipe.request_helpers import RecipeDuplicate
from mealie.schema.user.user import GroupInDB, PrivateUser from mealie.schema.user.user import GroupInDB, PrivateUser
from mealie.services._base_service import BaseService from mealie.services._base_service import BaseService
from mealie.services.recipe.recipe_data_service import RecipeDataService from mealie.services.recipe.recipe_data_service import RecipeDataService
@ -174,6 +178,64 @@ class RecipeService(BaseService):
return recipe return recipe
def duplicate_one(self, old_slug: str, dup_data: RecipeDuplicate) -> Recipe:
"""Duplicates a recipe and returns the new recipe."""
old_recipe = self._get_recipe(old_slug)
new_recipe = old_recipe.copy(exclude={"id", "name", "slug", "image", "comments"})
# Asset images in steps directly link to the original recipe, so we
# need to update them to references to the assets we copy below
def replace_recipe_step(step: RecipeStep) -> RecipeStep:
new_step = step.copy(exclude={"id", "text"})
new_step.id = uuid4()
new_step.text = step.text.replace(str(old_recipe.id), str(new_recipe.id))
return new_step
# Copy ingredients to make them independent of the original
def copy_recipe_ingredient(ingredient: RecipeIngredient):
new_ingredient = ingredient.copy(exclude={"reference_id"})
new_ingredient.reference_id = uuid4()
return new_ingredient
new_name = dup_data.name if dup_data.name else old_recipe.name or ""
new_recipe.id = uuid4()
new_recipe.slug = slugify(new_name)
new_recipe.image = cache.cache_key.new_key() if old_recipe.image else None
new_recipe.recipe_instructions = (
None
if old_recipe.recipe_instructions is None
else list(map(replace_recipe_step, old_recipe.recipe_instructions))
)
new_recipe.recipe_ingredient = (
None
if old_recipe.recipe_ingredient is None
else list(map(copy_recipe_ingredient, old_recipe.recipe_ingredient))
)
new_recipe = self._recipe_creation_factory(
self.user,
new_name,
additional_attrs=new_recipe.dict(),
)
new_recipe = self.repos.recipes.create(new_recipe)
# Copy all assets (including images) to the new recipe directory
# This assures that replaced links in recipe steps continue to work when the old recipe is deleted
try:
new_service = RecipeDataService(new_recipe.id, group_id=old_recipe.group_id)
old_service = RecipeDataService(old_recipe.id, group_id=old_recipe.group_id)
copytree(
old_service.dir_data,
new_service.dir_data,
dirs_exist_ok=True,
)
except Exception as e:
self.logger.error(f"Failed to copy assets from {old_recipe.slug} to {new_recipe.slug}: {e}")
return new_recipe
def _pre_update_check(self, slug: str, new_data: Recipe) -> Recipe: def _pre_update_check(self, slug: str, new_data: Recipe) -> Recipe:
""" """
gets the recipe from the database and performs a check to see if the user can update the recipe. gets the recipe from the database and performs a check to see if the user can update the recipe.

View File

@ -192,6 +192,87 @@ def test_read_update(
assert cats[0]["name"] in test_name assert cats[0]["name"] in test_name
@pytest.mark.parametrize("recipe_data", recipe_test_data)
def test_duplicate(api_client: TestClient, recipe_data: RecipeSiteTestCase, unique_user: TestUser):
# Initial get of the original recipe
original_recipe_url = api_routes.recipes_slug(recipe_data.expected_slug)
response = api_client.get(original_recipe_url, headers=unique_user.token)
assert response.status_code == 200
initial_recipe = json.loads(response.text)
# Duplicate the recipe
recipe_duplicate_url = api_routes.recipes_slug_duplicate(recipe_data.expected_slug)
response = api_client.post(
recipe_duplicate_url,
headers=unique_user.token,
json={
"name": "Test Duplicate",
},
)
assert response.status_code == 201
duplicate_recipe = json.loads(response.text)
assert duplicate_recipe["id"] != initial_recipe["id"]
assert duplicate_recipe["slug"].startswith("test-duplicate")
assert duplicate_recipe["name"].startswith("Test Duplicate")
# Image should be copied (if it exists)
assert (
duplicate_recipe["image"] is None
and initial_recipe["image"] is None
or duplicate_recipe["image"] != initial_recipe["image"]
)
# Number of steps should be the same, but the text may have changed (link replacements)
assert len(duplicate_recipe["recipeInstructions"]) == len(initial_recipe["recipeInstructions"])
# Ingredients should have the same texts, but different ids
assert duplicate_recipe["recipeIngredient"] != initial_recipe["recipeIngredient"]
assert list(map(lambda i: i["note"], duplicate_recipe["recipeIngredient"])) == list(
map(lambda i: i["note"], initial_recipe["recipeIngredient"])
)
previous_categories = initial_recipe["recipeCategory"]
assert duplicate_recipe["recipeCategory"] == previous_categories
# Edit the duplicated recipe to make sure it doesn't affect the original
dup_notes = duplicate_recipe["notes"] or []
dup_notes.append({"title": "Test", "text": "Test"})
duplicate_recipe["notes"] = dup_notes
duplicate_recipe["recipeIngredient"][0]["note"] = "Different Ingredient"
new_recipe_url = api_routes.recipes_slug(duplicate_recipe.get("slug"))
response = api_client.put(new_recipe_url, json=duplicate_recipe, headers=unique_user.token)
assert response.status_code == 200
edited_recipe = json.loads(response.text)
# reload original
response = api_client.get(original_recipe_url, headers=unique_user.token)
assert response.status_code == 200
original_recipe = json.loads(response.text)
assert edited_recipe["notes"] == dup_notes
assert original_recipe.get("notes") != edited_recipe.get("notes")
assert original_recipe.get("recipeCategory") == previous_categories
# Make sure ingredient edits don't affect the original
original_ingredients = original_recipe.get("recipeIngredient")
edited_ingredients = edited_recipe.get("recipeIngredient")
assert len(original_ingredients) == len(edited_ingredients)
assert original_ingredients[0]["note"] != edited_ingredients[0]["note"]
assert edited_ingredients[0]["note"] == "Different Ingredient"
assert original_ingredients[0]["referenceId"] != edited_ingredients[1]["referenceId"]
for i in range(1, len(original_ingredients)):
assert original_ingredients[i]["referenceId"] != edited_ingredients[i]["referenceId"]
def copy_info(ing):
return {k: v for k, v in ing.items() if k != "referenceId"}
assert copy_info(original_ingredients[i]) == copy_info(edited_ingredients[i])
@pytest.mark.parametrize("recipe_data", recipe_test_data) @pytest.mark.parametrize("recipe_data", recipe_test_data)
def test_rename(api_client: TestClient, recipe_data: RecipeSiteTestCase, unique_user: TestUser): def test_rename(api_client: TestClient, recipe_data: RecipeSiteTestCase, unique_user: TestUser):
recipe_url = api_routes.recipes_slug(recipe_data.expected_slug) recipe_url = api_routes.recipes_slug(recipe_data.expected_slug)

View File

@ -349,6 +349,11 @@ def recipes_slug_comments(slug):
return f"{prefix}/recipes/{slug}/comments" return f"{prefix}/recipes/{slug}/comments"
def recipes_slug_duplicate(slug):
"""`/api/recipes/{slug}/duplicate`"""
return f"{prefix}/recipes/{slug}/duplicate"
def recipes_slug_exports(slug): def recipes_slug_exports(slug):
"""`/api/recipes/{slug}/exports`""" """`/api/recipes/{slug}/exports`"""
return f"{prefix}/recipes/{slug}/exports" return f"{prefix}/recipes/{slug}/exports"