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>
|
</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>
|
||||||
<AppButtonUpload
|
<div class="d-flex gap-2">
|
||||||
class="ml-auto"
|
<AppButtonUpload
|
||||||
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")],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -59,7 +59,6 @@
|
|||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="canDelete"
|
v-if="canDelete"
|
||||||
delete
|
delete
|
||||||
secondary
|
|
||||||
@click="deleteEvent"
|
@click="deleteEvent"
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user