feat: duplicate recipes (#1750)

* feature/frontend: Add duplicate button to recipe

* feature/backend: Add recipe duplication endpoint

* feature/frontend: add duplication API call

* Regenerate API docs

* Fix linter errors

* Fix backend linter error

* Move recipe duplication logic to recipe service

* Add test for recipe duplication

* Improve recipe ingredients copy test

* generate types

* import type

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
Philipp
2022-12-01 06:57:26 +01:00
committed by GitHub
parent e73a72959c
commit 33dffccaa5
18 changed files with 258 additions and 25 deletions

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -31,7 +31,7 @@ from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
from mealie.schema.recipe.recipe_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."""

View File

@@ -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",

View File

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

View File

@@ -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.