mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
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:
parent
e73a72959c
commit
33dffccaa5
File diff suppressed because one or more lines are too long
@ -54,6 +54,7 @@
|
||||
delete: false,
|
||||
edit: false,
|
||||
download: true,
|
||||
duplicate: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: true,
|
||||
|
@ -13,6 +13,23 @@
|
||||
{{ $t("recipe.delete-confirmation") }}
|
||||
</v-card-text>
|
||||
</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
|
||||
v-model="mealplannerDialog"
|
||||
:title="$t('recipe.add-recipe-to-mealplan')"
|
||||
@ -136,6 +153,7 @@ export default defineComponent({
|
||||
delete: true,
|
||||
edit: true,
|
||||
download: true,
|
||||
duplicate: false,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: true,
|
||||
@ -199,6 +217,8 @@ export default defineComponent({
|
||||
recipeDeleteDialog: false,
|
||||
mealplannerDialog: false,
|
||||
shoppingListDialog: false,
|
||||
recipeDuplicateDialog: false,
|
||||
recipeName: props.name,
|
||||
loading: false,
|
||||
menuItems: [] as ContextMenuItem[],
|
||||
newMealdate: "",
|
||||
@ -230,6 +250,12 @@ export default defineComponent({
|
||||
color: undefined,
|
||||
event: "download",
|
||||
},
|
||||
duplicate: {
|
||||
title: i18n.tc("general.duplicate"),
|
||||
icon: $globals.icons.duplicate,
|
||||
color: undefined,
|
||||
event: "duplicate",
|
||||
},
|
||||
mealplanner: {
|
||||
title: i18n.tc("recipe.add-to-plan"),
|
||||
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();
|
||||
|
||||
// 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"),
|
||||
download: handleDownloadEvent,
|
||||
duplicate: () => {
|
||||
state.recipeDuplicateDialog = true;
|
||||
},
|
||||
mealplanner: () => {
|
||||
state.mealplannerDialog = true;
|
||||
},
|
||||
@ -376,6 +412,7 @@ export default defineComponent({
|
||||
...toRefs(state),
|
||||
shoppingLists,
|
||||
addRecipeToList,
|
||||
duplicateRecipe,
|
||||
contextMenuEventHandler,
|
||||
deleteRecipe,
|
||||
addRecipeToPlan,
|
||||
|
@ -8,7 +8,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "简体中文 (Chinese simplified)",
|
||||
value: "zh-CN",
|
||||
progress: 57,
|
||||
progress: 56,
|
||||
},
|
||||
{
|
||||
name: "Tiếng Việt (Vietnamese)",
|
||||
@ -23,7 +23,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Türkçe (Turkish)",
|
||||
value: "tr-TR",
|
||||
progress: 32,
|
||||
progress: 47,
|
||||
},
|
||||
{
|
||||
name: "Svenska (Swedish)",
|
||||
@ -38,12 +38,12 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Slovenian",
|
||||
value: "sl-SI",
|
||||
progress: 95,
|
||||
progress: 94,
|
||||
},
|
||||
{
|
||||
name: "Slovak",
|
||||
value: "sk-SK",
|
||||
progress: 86,
|
||||
progress: 85,
|
||||
},
|
||||
{
|
||||
name: "Pусский (Russian)",
|
||||
@ -53,7 +53,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Română (Romanian)",
|
||||
value: "ro-RO",
|
||||
progress: 4,
|
||||
progress: 3,
|
||||
},
|
||||
{
|
||||
name: "Português (Portuguese)",
|
||||
@ -63,27 +63,27 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Português do Brasil (Brazilian Portuguese)",
|
||||
value: "pt-BR",
|
||||
progress: 39,
|
||||
progress: 40,
|
||||
},
|
||||
{
|
||||
name: "Polski (Polish)",
|
||||
value: "pl-PL",
|
||||
progress: 88,
|
||||
progress: 89,
|
||||
},
|
||||
{
|
||||
name: "Norsk (Norwegian)",
|
||||
value: "no-NO",
|
||||
progress: 85,
|
||||
progress: 87,
|
||||
},
|
||||
{
|
||||
name: "Nederlands (Dutch)",
|
||||
value: "nl-NL",
|
||||
progress: 91,
|
||||
progress: 97,
|
||||
},
|
||||
{
|
||||
name: "Lithuanian",
|
||||
value: "lt-LT",
|
||||
progress: 0,
|
||||
progress: 64,
|
||||
},
|
||||
{
|
||||
name: "한국어 (Korean)",
|
||||
@ -98,12 +98,12 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Italiano (Italian)",
|
||||
value: "it-IT",
|
||||
progress: 83,
|
||||
progress: 82,
|
||||
},
|
||||
{
|
||||
name: "Magyar (Hungarian)",
|
||||
value: "hu-HU",
|
||||
progress: 78,
|
||||
progress: 77,
|
||||
},
|
||||
{
|
||||
name: "עברית (Hebrew)",
|
||||
@ -113,7 +113,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Français (French)",
|
||||
value: "fr-FR",
|
||||
progress: 100,
|
||||
progress: 99,
|
||||
},
|
||||
{
|
||||
name: "French, Canada",
|
||||
@ -123,12 +123,12 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Suomi (Finnish)",
|
||||
value: "fi-FI",
|
||||
progress: 23,
|
||||
progress: 22,
|
||||
},
|
||||
{
|
||||
name: "Español (Spanish)",
|
||||
value: "es-ES",
|
||||
progress: 95,
|
||||
progress: 94,
|
||||
},
|
||||
{
|
||||
name: "American English",
|
||||
@ -138,7 +138,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "British English",
|
||||
value: "en-GB",
|
||||
progress: 32,
|
||||
progress: 31,
|
||||
},
|
||||
{
|
||||
name: "Ελληνικά (Greek)",
|
||||
@ -148,17 +148,17 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Deutsch (German)",
|
||||
value: "de-DE",
|
||||
progress: 100,
|
||||
progress: 99,
|
||||
},
|
||||
{
|
||||
name: "Dansk (Danish)",
|
||||
value: "da-DK",
|
||||
progress: 100,
|
||||
progress: 99,
|
||||
},
|
||||
{
|
||||
name: "Čeština (Czech)",
|
||||
value: "cs-CZ",
|
||||
progress: 66,
|
||||
progress: 89,
|
||||
},
|
||||
{
|
||||
name: "Català (Catalan)",
|
||||
@ -178,6 +178,6 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Afrikaans (Afrikaans)",
|
||||
value: "af-ZA",
|
||||
progress: 0,
|
||||
progress: 9,
|
||||
},
|
||||
]
|
||||
|
@ -75,6 +75,7 @@
|
||||
"delete": "Löschen",
|
||||
"disabled": "Deaktiviert",
|
||||
"download": "Herunterladen",
|
||||
"duplicate": "Duplizieren",
|
||||
"edit": "Bearbeiten",
|
||||
"enabled": "Aktiviert",
|
||||
"exception": "Fehler",
|
||||
@ -281,6 +282,8 @@
|
||||
"description": "Beschreibung",
|
||||
"disable-amount": "Zutatenmenge deaktivieren",
|
||||
"disable-comments": "Kommentare deaktivieren",
|
||||
"duplicate": "Rezept duplizieren",
|
||||
"duplicate-name": "Name of the new recipe",
|
||||
"edit-scale": "Maßstab ändern",
|
||||
"fat-content": "Fett",
|
||||
"fiber-content": "Ballaststoffe",
|
||||
|
@ -75,6 +75,7 @@
|
||||
"delete": "Delete",
|
||||
"disabled": "Disabled",
|
||||
"download": "Download",
|
||||
"duplicate": "Duplicate",
|
||||
"edit": "Edit",
|
||||
"enabled": "Enabled",
|
||||
"exception": "Exception",
|
||||
@ -282,6 +283,8 @@
|
||||
"description": "Description",
|
||||
"disable-amount": "Disable Ingredient Amounts",
|
||||
"disable-comments": "Disable Comments",
|
||||
"duplicate": "Duplicate recipe",
|
||||
"duplicate-name": "Name of the new recipe",
|
||||
"edit-scale": "Edit Scale",
|
||||
"fat-content": "Fat",
|
||||
"fiber-content": "Fiber",
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Recipe } from "../types/recipe";
|
||||
import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
|
||||
|
||||
export interface CrudAPIInterface {
|
||||
@ -20,8 +21,7 @@ export abstract class BaseAPI {
|
||||
|
||||
export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType>
|
||||
extends BaseAPI
|
||||
implements CrudAPIInterface
|
||||
{
|
||||
implements CrudAPIInterface {
|
||||
abstract baseRoute: string;
|
||||
abstract itemRoute(itemId: string | number): string;
|
||||
|
||||
@ -50,4 +50,10 @@ export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType>
|
||||
async deleteOne(itemId: string | number) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -302,6 +302,9 @@ export interface RecipeCommentUpdate {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
export interface RecipeDuplicate {
|
||||
name?: string;
|
||||
}
|
||||
export interface RecipePaginationQuery {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
|
@ -123,6 +123,7 @@ import {
|
||||
mdiText,
|
||||
mdiTextBoxOutline,
|
||||
mdiChefHat,
|
||||
mdiContentDuplicate,
|
||||
} from "@mdi/js";
|
||||
|
||||
export const icons = {
|
||||
@ -173,6 +174,7 @@ export const icons = {
|
||||
dotsHorizontal: mdiDotsHorizontal,
|
||||
dotsVertical: mdiDotsVertical,
|
||||
download: mdiDownload,
|
||||
duplicate: mdiContentDuplicate,
|
||||
email: mdiEmail,
|
||||
externalLink: mdiLinkVariant,
|
||||
eye: mdiEye,
|
||||
|
@ -243,6 +243,7 @@
|
||||
delete: false,
|
||||
edit: false,
|
||||
download: true,
|
||||
duplicate: false,
|
||||
mealplanner: false,
|
||||
print: true,
|
||||
share: false,
|
||||
|
@ -17,6 +17,7 @@
|
||||
"generic-updated": "{name} wurde aktualisiert",
|
||||
"generic-created-with-url": "{name} wurde erstellt, {url}",
|
||||
"generic-updated-with-url": "{name} wurde aktualisiert, {url}",
|
||||
"generic-duplicated": "{name} wurde dupliziert",
|
||||
"generic-deleted": "{name} wurde gelöscht"
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,7 @@
|
||||
"generic-updated": "{name} was updated",
|
||||
"generic-created-with-url": "{name} has been created, {url}",
|
||||
"generic-updated-with-url": "{name} has been updated, {url}",
|
||||
"generic-duplicated": "{name} has been duplicated",
|
||||
"generic-deleted": "{name} has been deleted"
|
||||
}
|
||||
}
|
||||
|
@ -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_settings import RecipeSettings
|
||||
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.services import urls
|
||||
from mealie.services.event_bus_service.event_types import (
|
||||
@ -298,6 +298,26 @@ class RecipeController(BaseRecipeController):
|
||||
|
||||
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}")
|
||||
def update_one(self, slug: str, data: Recipe):
|
||||
"""Updates a recipe by existing slug and data."""
|
||||
|
@ -76,9 +76,10 @@ from .recipe_timeline_events import (
|
||||
RecipeTimelineEventOut,
|
||||
RecipeTimelineEventPagination,
|
||||
RecipeTimelineEventUpdate,
|
||||
TimelineEventType,
|
||||
)
|
||||
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__ = [
|
||||
"RecipeToolCreate",
|
||||
@ -90,12 +91,14 @@ __all__ = [
|
||||
"RecipeTimelineEventOut",
|
||||
"RecipeTimelineEventPagination",
|
||||
"RecipeTimelineEventUpdate",
|
||||
"TimelineEventType",
|
||||
"RecipeAsset",
|
||||
"RecipeSettings",
|
||||
"RecipeShareToken",
|
||||
"RecipeShareTokenCreate",
|
||||
"RecipeShareTokenSave",
|
||||
"RecipeShareTokenSummary",
|
||||
"RecipeDuplicate",
|
||||
"RecipeSlug",
|
||||
"RecipeZipTokenResponse",
|
||||
"SlugResponse",
|
||||
|
@ -20,3 +20,7 @@ class UpdateImageResponse(BaseModel):
|
||||
|
||||
class RecipeZipTokenResponse(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class RecipeDuplicate(BaseModel):
|
||||
name: str | None
|
||||
|
@ -3,17 +3,21 @@ import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from shutil import copytree, rmtree
|
||||
from uuid import uuid4
|
||||
from zipfile import ZipFile
|
||||
|
||||
from fastapi import UploadFile
|
||||
from slugify import slugify
|
||||
|
||||
from mealie.core import exceptions
|
||||
from mealie.pkgs import cache
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
|
||||
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
|
||||
from mealie.schema.recipe.recipe_settings import RecipeSettings
|
||||
from mealie.schema.recipe.recipe_step import RecipeStep
|
||||
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.services._base_service import BaseService
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
@ -174,6 +178,64 @@ class RecipeService(BaseService):
|
||||
|
||||
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:
|
||||
"""
|
||||
gets the recipe from the database and performs a check to see if the user can update the recipe.
|
||||
|
@ -192,6 +192,87 @@ def test_read_update(
|
||||
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)
|
||||
def test_rename(api_client: TestClient, recipe_data: RecipeSiteTestCase, unique_user: TestUser):
|
||||
recipe_url = api_routes.recipes_slug(recipe_data.expected_slug)
|
||||
|
@ -349,6 +349,11 @@ def recipes_slug_comments(slug):
|
||||
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):
|
||||
"""`/api/recipes/{slug}/exports`"""
|
||||
return f"{prefix}/recipes/{slug}/exports"
|
||||
|
Loading…
x
Reference in New Issue
Block a user