mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-30 17:53:31 -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:
		
										
											
												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" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user