mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -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, |           delete: false, | ||||||
|           edit: false, |           edit: false, | ||||||
|           download: true, |           download: true, | ||||||
|  |           duplicate: true, | ||||||
|           mealplanner: true, |           mealplanner: true, | ||||||
|           shoppingList: true, |           shoppingList: true, | ||||||
|           print: true, |           print: true, | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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, | ||||||
|   }, |   }, | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -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", | ||||||
|   | |||||||
| @@ -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", | ||||||
|   | |||||||
| @@ -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, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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; | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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" | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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" | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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.""" | ||||||
|   | |||||||
| @@ -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", | ||||||
|   | |||||||
| @@ -20,3 +20,7 @@ class UpdateImageResponse(BaseModel): | |||||||
|  |  | ||||||
| class RecipeZipTokenResponse(BaseModel): | class RecipeZipTokenResponse(BaseModel): | ||||||
|     token: str |     token: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class RecipeDuplicate(BaseModel): | ||||||
|  |     name: str | None | ||||||
|   | |||||||
| @@ -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. | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
| @@ -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" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user