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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 258 additions and 25 deletions

File diff suppressed because one or more lines are too long

View File

@ -54,6 +54,7 @@
delete: false,
edit: false,
download: true,
duplicate: true,
mealplanner: true,
shoppingList: true,
print: true,

View File

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

View File

@ -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,
},
]

View File

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

View File

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

View File

@ -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,
});
}
}

View File

@ -302,6 +302,9 @@ export interface RecipeCommentUpdate {
id: string;
text: string;
}
export interface RecipeDuplicate {
name?: string;
}
export interface RecipePaginationQuery {
page?: number;
perPage?: number;

View File

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

View File

@ -243,6 +243,7 @@
delete: false,
edit: false,
download: true,
duplicate: false,
mealplanner: false,
print: true,
share: false,

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.

View File

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

View File

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