feat: Add DELETE /{slug}/image (#6259)

Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
Christian Hollinger
2025-11-03 20:41:54 -05:00
committed by GitHub
parent 7bb0f0801a
commit bb67d993a0
12 changed files with 150 additions and 16 deletions

File diff suppressed because one or more lines are too long

View File

@@ -20,18 +20,36 @@
</v-btn> </v-btn>
</template> </template>
<v-card width="400"> <v-card width="400">
<v-card-title class="headline flex mb-0"> <v-card-title class="headline flex-wrap mb-0">
<div> <div>
{{ $t("recipe.recipe-image") }} {{ $t("recipe.recipe-image") }}
</div> </div>
<div class="d-flex gap-2">
<AppButtonUpload <AppButtonUpload
class="ml-auto"
url="none" url="none"
file-name="image" file-name="image"
:text-btn="false" :text-btn="false"
:post="false" :post="false"
@uploaded="uploadImage" @uploaded="uploadImage"
/> />
<BaseButton
class="ml-2"
delete
@click="dialogDeleteImage = true"
/>
<BaseDialog
v-model="dialogDeleteImage"
:title="$t('recipe.delete-image')"
:icon="$globals.icons.alertCircle"
color="error"
can-delete
@delete="deleteImage"
>
<v-card-text>
{{ $t("recipe.delete-image-confirmation") }}
</v-card-text>
</BaseDialog>
</div>
</v-card-title> </v-card-title>
<v-card-text class="mt-n5"> <v-card-text class="mt-n5">
<div> <div>
@@ -62,38 +80,58 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload"; const UPLOAD_EVENT = "upload";
const DELETE_EVENT = "delete";
const props = defineProps<{ slug: string }>(); const props = defineProps<{ slug: string }>();
const emit = defineEmits<{ const emit = defineEmits<{
refresh: []; refresh: [];
upload: [fileObject: File]; upload: [fileObject: File];
delete: [];
}>(); }>();
const i18n = useI18n();
const api = useUserApi();
const url = ref(""); const url = ref("");
const loading = ref(false); const loading = ref(false);
const menu = ref(false); const menu = ref(false);
const dialogDeleteImage = ref(false);
function uploadImage(fileObject: File) { function uploadImage(fileObject: File) {
emit(UPLOAD_EVENT, fileObject); emit(UPLOAD_EVENT, fileObject);
menu.value = false; menu.value = false;
} }
const api = useUserApi(); async function deleteImage() {
loading.value = true;
try {
await api.recipes.deleteImage(props.slug);
emit(DELETE_EVENT);
menu.value = false;
}
catch (e) {
alert.error(i18n.t("events.something-went-wrong"));
console.error("Failed to delete image", e);
}
finally {
loading.value = false;
}
}
async function getImageFromURL() { async function getImageFromURL() {
loading.value = true; loading.value = true;
if (await api.recipes.updateImagebyURL(props.slug, url.value)) { if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
emit(REFRESH_EVENT); emit(DELETE_EVENT);
} }
loading.value = false; loading.value = false;
menu.value = false; menu.value = false;
} }
const i18n = useI18n();
const messages = computed(() => const messages = computed(() =>
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")], props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
); );

View File

@@ -5,6 +5,7 @@
:slug="recipe.slug" :slug="recipe.slug"
@upload="uploadImage" @upload="uploadImage"
@refresh="imageKey++" @refresh="imageKey++"
@delete="deleteImage"
/> />
<RecipeSettingsMenu <RecipeSettingsMenu
v-model="recipe.settings" v-model="recipe.settings"
@@ -78,4 +79,10 @@ async function uploadImage(fileObject: File) {
} }
imageKey.value++; imageKey.value++;
} }
async function deleteImage() {
// The image is already deleted on the backend, just need to update the UI
recipe.value.image = "";
imageKey.value++;
}
</script> </script>

View File

@@ -59,7 +59,6 @@
<BaseButton <BaseButton
v-if="canDelete" v-if="canDelete"
delete delete
secondary
@click="deleteEvent" @click="deleteEvent"
/> />
<BaseButton <BaseButton

View File

@@ -515,6 +515,9 @@
"recipe-deleted": "Recipe deleted", "recipe-deleted": "Recipe deleted",
"recipe-image": "Recipe Image", "recipe-image": "Recipe Image",
"recipe-image-updated": "Recipe image updated", "recipe-image-updated": "Recipe image updated",
"delete-image": "Delete Recipe Image",
"delete-image-confirmation": "Are you sure you want to delete this recipe image?",
"recipe-image-deleted": "Recipe image deleted",
"recipe-name": "Recipe Name", "recipe-name": "Recipe Name",
"recipe-settings": "Recipe Settings", "recipe-settings": "Recipe Settings",
"recipe-update-failed": "Recipe update failed", "recipe-update-failed": "Recipe update failed",

View File

@@ -138,6 +138,10 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return this.requests.post<UpdateImageResponse>(routes.recipesRecipeSlugImage(slug), { url }); return this.requests.post<UpdateImageResponse>(routes.recipesRecipeSlugImage(slug), { url });
} }
deleteImage(slug: string) {
return this.requests.delete<string>(routes.recipesRecipeSlugImage(slug));
}
async testCreateOneUrl(url: string, useOpenAI = false) { async testCreateOneUrl(url: string, useOpenAI = false) {
return await this.requests.post<Recipe | null>(routes.recipesTestScrapeUrl, { url, useOpenAI }); return await this.requests.post<Recipe | null>(routes.recipesTestScrapeUrl, { url, useOpenAI });
} }

View File

@@ -5,6 +5,7 @@
"recipe": { "recipe": {
"unique-name-error": "Recipe names must be unique", "unique-name-error": "Recipe names must be unique",
"recipe-created": "Recipe Created", "recipe-created": "Recipe Created",
"recipe-image-deleted": "Recipe image deleted",
"recipe-defaults": { "recipe-defaults": {
"ingredient-note": "1 Cup Flour", "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" "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"

View File

@@ -154,6 +154,11 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
return entry.image 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): def count_uncategorized(self, count=True, override_schema=None):
return self._count_attribute( return self._count_attribute(
attribute_name=RecipeModel.recipe_category, attribute_name=RecipeModel.recipe_category,

View File

@@ -46,7 +46,7 @@ from mealie.schema.recipe.request_helpers import (
) )
from mealie.schema.response import PaginationBase, PaginationQuery from mealie.schema.response import PaginationBase, PaginationQuery
from mealie.schema.response.pagination import RecipeSearchQuery 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 import urls
from mealie.services.event_bus_service.event_types import ( from mealie.services.event_bus_service.event_types import (
EventOperation, EventOperation,
@@ -543,6 +543,15 @@ class RecipeController(BaseRecipeController):
self.handle_exceptions(e) self.handle_exceptions(e)
return None 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"]) @router.post("/{slug}/assets", response_model=RecipeAsset, tags=["Recipe: Images and Assets"])
def upload_recipe_asset( def upload_recipe_asset(
self, self,

View File

@@ -8,6 +8,7 @@ from pydantic import UUID4
from mealie.pkgs import img, safehttp from mealie.pkgs import img, safehttp
from mealie.pkgs.safehttp.transport import AsyncSafeTransport from mealie.pkgs.safehttp.transport import AsyncSafeTransport
from mealie.schema.recipe.recipe import Recipe 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._base_service import BaseService
from mealie.services.scraper.user_agents_manager import get_user_agents_manager from mealie.services.scraper.user_agents_manager import get_user_agents_manager
@@ -104,6 +105,14 @@ class RecipeDataService(BaseService):
return image_path 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: async def scrape_image(self, image_url: str | dict[str, str] | list[str]) -> None:
self.logger.info(f"Image URL: {image_url}") self.logger.info(f"Image URL: {image_url}")
user_agent = get_user_agents_manager().user_agents[0] user_agent = get_user_agents_manager().user_agents[0]

View File

@@ -418,6 +418,17 @@ class RecipeService(RecipeServiceBase):
return self.group_recipes.update_image(slug, extension) 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: def patch_one(self, slug_or_id: str | UUID, patch_data: Recipe) -> Recipe:
recipe: Recipe = self._pre_update_check(slug_or_id, patch_data) recipe: Recipe = self._pre_update_check(slug_or_id, patch_data)

View File

@@ -6,6 +6,7 @@ from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import Recipe from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_settings import RecipeSettings from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.services.recipe.recipe_service import RecipeDataService
from tests import data from tests import data
from tests.utils import api_routes from tests.utils import api_routes
from tests.utils.factories import random_string 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) response = api_client.get(api_routes.recipes_slug(recipe_id), headers=unique_user.token)
recipe_respons = response.json() recipe_respons = response.json()
assert recipe_respons["image"] is not None 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())