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

View File

File diff suppressed because one or more lines are too long

View File

@@ -20,18 +20,36 @@
</v-btn>
</template>
<v-card width="400">
<v-card-title class="headline flex mb-0">
<v-card-title class="headline flex-wrap mb-0">
<div>
{{ $t("recipe.recipe-image") }}
</div>
<AppButtonUpload
class="ml-auto"
url="none"
file-name="image"
:text-btn="false"
:post="false"
@uploaded="uploadImage"
/>
<div class="d-flex gap-2">
<AppButtonUpload
url="none"
file-name="image"
:text-btn="false"
:post="false"
@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-text class="mt-n5">
<div>
@@ -62,38 +80,58 @@
</template>
<script setup lang="ts">
import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload";
const DELETE_EVENT = "delete";
const props = defineProps<{ slug: string }>();
const emit = defineEmits<{
refresh: [];
upload: [fileObject: File];
delete: [];
}>();
const i18n = useI18n();
const api = useUserApi();
const url = ref("");
const loading = ref(false);
const menu = ref(false);
const dialogDeleteImage = ref(false);
function uploadImage(fileObject: File) {
emit(UPLOAD_EVENT, fileObject);
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() {
loading.value = true;
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
emit(REFRESH_EVENT);
emit(DELETE_EVENT);
}
loading.value = false;
menu.value = false;
}
const i18n = useI18n();
const messages = computed(() =>
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
);

View File

@@ -5,6 +5,7 @@
:slug="recipe.slug"
@upload="uploadImage"
@refresh="imageKey++"
@delete="deleteImage"
/>
<RecipeSettingsMenu
v-model="recipe.settings"
@@ -78,4 +79,10 @@ async function uploadImage(fileObject: File) {
}
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>

View File

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

View File

@@ -515,6 +515,9 @@
"recipe-deleted": "Recipe deleted",
"recipe-image": "Recipe Image",
"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-settings": "Recipe Settings",
"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 });
}
deleteImage(slug: string) {
return this.requests.delete<string>(routes.recipesRecipeSlugImage(slug));
}
async testCreateOneUrl(url: string, useOpenAI = false) {
return await this.requests.post<Recipe | null>(routes.recipesTestScrapeUrl, { url, useOpenAI });
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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