mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-11-09 05:26:25 -05:00
feat: Add DELETE /{slug}/image (#6259)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
7bb0f0801a
commit
bb67d993a0
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
<div class="d-flex gap-2">
|
||||
<AppButtonUpload
|
||||
class="ml-auto"
|
||||
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")],
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
<BaseButton
|
||||
v-if="canDelete"
|
||||
delete
|
||||
secondary
|
||||
@click="deleteEvent"
|
||||
/>
|
||||
<BaseButton
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user