mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-15 06:45:23 -05: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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user