diff --git a/docs/docs/overrides/api.html b/docs/docs/overrides/api.html index 637c4237b..80f88d9aa 100644 --- a/docs/docs/overrides/api.html +++ b/docs/docs/overrides/api.html @@ -14,7 +14,7 @@
diff --git a/frontend/components/Domain/Recipe/RecipeImageUploadBtn.vue b/frontend/components/Domain/Recipe/RecipeImageUploadBtn.vue index edac19879..324c60fbc 100644 --- a/frontend/components/Domain/Recipe/RecipeImageUploadBtn.vue +++ b/frontend/components/Domain/Recipe/RecipeImageUploadBtn.vue @@ -20,18 +20,36 @@ - +
{{ $t("recipe.recipe-image") }}
- +
+ + + + + {{ $t("recipe.delete-image-confirmation") }} + + +
@@ -62,38 +80,58 @@ diff --git a/frontend/components/global/BaseDialog.vue b/frontend/components/global/BaseDialog.vue index 73ecf66ca..468f2df20 100644 --- a/frontend/components/global/BaseDialog.vue +++ b/frontend/components/global/BaseDialog.vue @@ -59,7 +59,6 @@ { return this.requests.post(routes.recipesRecipeSlugImage(slug), { url }); } + deleteImage(slug: string) { + return this.requests.delete(routes.recipesRecipeSlugImage(slug)); + } + async testCreateOneUrl(url: string, useOpenAI = false) { return await this.requests.post(routes.recipesTestScrapeUrl, { url, useOpenAI }); } diff --git a/mealie/lang/messages/en-US.json b/mealie/lang/messages/en-US.json index 1f6abf614..cf38657e0 100644 --- a/mealie/lang/messages/en-US.json +++ b/mealie/lang/messages/en-US.json @@ -5,6 +5,7 @@ "recipe": { "unique-name-error": "Recipe names must be unique", "recipe-created": "Recipe Created", + "recipe-image-deleted": "Recipe image deleted", "recipe-defaults": { "ingredient-note": "1 Cup Flour", "step-text": "Recipe steps as well as other fields in the recipe page support markdown syntax.\n\n**Add a link**\n\n[My Link](https://demo.mealie.io)\n" diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index 354efa413..a9982553d 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -154,6 +154,11 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]): return entry.image + def delete_image(self, slug: str, _: str | None = None): + entry: RecipeModel = self._query_one(match_value=slug) + entry.image = None + self.session.commit() + def count_uncategorized(self, count=True, override_schema=None): return self._count_attribute( attribute_name=RecipeModel.recipe_category, diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 5232bf339..a65ac305f 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -46,7 +46,7 @@ from mealie.schema.recipe.request_helpers import ( ) from mealie.schema.response import PaginationBase, PaginationQuery from mealie.schema.response.pagination import RecipeSearchQuery -from mealie.schema.response.responses import ErrorResponse +from mealie.schema.response.responses import ErrorResponse, SuccessResponse from mealie.services import urls from mealie.services.event_bus_service.event_types import ( EventOperation, @@ -543,6 +543,15 @@ class RecipeController(BaseRecipeController): self.handle_exceptions(e) return None + @router.delete("/{slug}/image", tags=["Recipe: Images and Assets"]) + def delete_recipe_image(self, slug: str): + try: + self.service.delete_recipe_image(slug) + return SuccessResponse.respond(message=self.t("recipe.recipe-image-deleted")) + except Exception as e: + self.handle_exceptions(e) + return None + @router.post("/{slug}/assets", response_model=RecipeAsset, tags=["Recipe: Images and Assets"]) def upload_recipe_asset( self, diff --git a/mealie/services/recipe/recipe_data_service.py b/mealie/services/recipe/recipe_data_service.py index f92270aff..be4611bfa 100644 --- a/mealie/services/recipe/recipe_data_service.py +++ b/mealie/services/recipe/recipe_data_service.py @@ -8,6 +8,7 @@ from pydantic import UUID4 from mealie.pkgs import img, safehttp from mealie.pkgs.safehttp.transport import AsyncSafeTransport from mealie.schema.recipe.recipe import Recipe +from mealie.schema.recipe.recipe_image_types import RecipeImageTypes from mealie.services._base_service import BaseService from mealie.services.scraper.user_agents_manager import get_user_agents_manager @@ -104,6 +105,14 @@ class RecipeDataService(BaseService): return image_path + def delete_image(self, image_dir: Path | None = None): + if not image_dir: + image_dir = self.dir_image + + for img_type in RecipeImageTypes: + image_path = image_dir.joinpath(img_type.value) + image_path.unlink(missing_ok=True) + async def scrape_image(self, image_url: str | dict[str, str] | list[str]) -> None: self.logger.info(f"Image URL: {image_url}") user_agent = get_user_agents_manager().user_agents[0] diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 381793eb3..43782725f 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -418,6 +418,17 @@ class RecipeService(RecipeServiceBase): return self.group_recipes.update_image(slug, extension) + def delete_recipe_image(self, slug: str) -> None: + recipe = self.get_one(slug) + if not self.can_update(recipe): + raise exceptions.PermissionDenied("You do not have permission to edit this recipe.") + + data_service = RecipeDataService(recipe.id) + data_service.delete_image() + + self.group_recipes.delete_image(slug) + return None + def patch_one(self, slug_or_id: str | UUID, patch_data: Recipe) -> Recipe: recipe: Recipe = self._pre_update_check(slug_or_id, patch_data) diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py index 3bfe0191a..0a355dac6 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py @@ -6,6 +6,7 @@ from fastapi.testclient import TestClient from mealie.repos.repository_factory import AllRepositories from mealie.schema.recipe.recipe import Recipe from mealie.schema.recipe.recipe_settings import RecipeSettings +from mealie.services.recipe.recipe_service import RecipeDataService from tests import data from tests.utils import api_routes from tests.utils.factories import random_string @@ -211,3 +212,50 @@ def test_user_can_update_recipe_image(api_client: TestClient, unique_user: TestU response = api_client.get(api_routes.recipes_slug(recipe_id), headers=unique_user.token) recipe_respons = response.json() assert recipe_respons["image"] is not None + + service = RecipeDataService(recipe_json.id) + assert service.dir_image.exists() and any(f.is_file() for f in service.dir_image.iterdir()) + + +def test_user_can_delete_recipe_image(api_client: TestClient, unique_user: TestUser): + data_payload = {"extension": "jpg"} + file_payload = {"image": data.images_test_image_1.read_bytes()} + + household = unique_user.repos.households.get_one(unique_user.household_id) + assert household and household.preferences + household.preferences.private_household = True + household.preferences.lock_recipe_edits_from_other_households = True + unique_user.repos.household_preferences.update(household.id, household.preferences) + + response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token) + assert response.status_code == 201 + recipe_json = unique_user.repos.recipes.get_one(response.json()) + assert recipe_json and recipe_json.id + assert recipe_json.image is None + recipe_id = str(recipe_json.id) + + response = api_client.put( + api_routes.recipes_slug_image(recipe_json.slug), + data=data_payload, + files=file_payload, + headers=unique_user.token, + ) + assert response.status_code == 200 + + response = api_client.get(api_routes.recipes_slug(recipe_id), headers=unique_user.token) + recipe_respons = response.json() + assert recipe_respons["image"] is not None + + service = RecipeDataService(recipe_json.id) + assert service.dir_image.exists() and any(f.is_file() for f in service.dir_image.iterdir()) + + response = api_client.delete( + api_routes.recipes_slug_image(recipe_json.slug), + headers=unique_user.token, + ) + assert response.status_code == 200 + + response = api_client.get(api_routes.recipes_slug(recipe_id), headers=unique_user.token) + recipe_respons = response.json() + assert recipe_respons["image"] is None + assert not service.dir_image.exists() or not any(f.is_file() for f in service.dir_image.iterdir())