Skip to content

Commit 64d8786

Browse files
fix: Improve recipe bulk deletion (#6772)
1 parent 0971d59 commit 64d8786

File tree

6 files changed

+121
-39
lines changed

6 files changed

+121
-39
lines changed

mealie/repos/repository_factory.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sqlalchemy import select
66
from sqlalchemy.orm import Session
77

8+
from mealie.db.models._model_utils.guid import GUID
89
from mealie.db.models.group import Group, ReportEntryModel, ReportModel
910
from mealie.db.models.group.exports import GroupDataExportsModel
1011
from mealie.db.models.group.preferences import GroupPreferencesModel
@@ -117,6 +118,12 @@ def __init__(
117118
self.group_id = group_id
118119
self.household_id = household_id
119120

121+
def uuid_to_str(self, val: UUID4) -> str:
122+
"""Helper method to convert a UUID into a database-safe string"""
123+
124+
dialect = self.session.bind.dialect
125+
return GUID.convert_value_to_guid(val, dialect) or ""
126+
120127
# ================================================================
121128
# Recipe
122129

mealie/repos/repository_recipes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def delete(self, value, match_key: str | None = None) -> Recipe:
130130
return self._delete_recipe(recipe_in_db)
131131

132132
def delete_many(self, values: Iterable) -> list[Recipe]:
133-
query = self._query().filter(self.model.id.in_(values))
133+
query = self._query().filter(self.model.slug.in_(values)).filter_by(**self._filter_builder())
134134
recipes_in_db = self.session.execute(query).unique().scalars().all()
135135
results: list[Recipe] = []
136136

mealie/routes/recipe/bulk_actions.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from functools import cached_property
22
from pathlib import Path
33

4-
from fastapi import APIRouter, HTTPException
4+
from fastapi import APIRouter, HTTPException, status
55
from pydantic import UUID4
66

77
from mealie.core.dependencies.dependencies import get_temporary_zip_path
8+
from mealie.core.exceptions import PermissionDenied
89
from mealie.core.security import create_file_token
910
from mealie.routes._base import BaseUserController, controller
1011
from mealie.schema.group.group_exports import GroupDataExport
@@ -15,8 +16,9 @@
1516
DeleteRecipes,
1617
ExportRecipes,
1718
)
18-
from mealie.schema.response.responses import SuccessResponse
19+
from mealie.schema.response.responses import ErrorResponse, SuccessResponse
1920
from mealie.services.recipe.recipe_bulk_service import RecipeBulkActionsService
21+
from mealie.services.recipe.recipe_service import RecipeService
2022

2123
router = APIRouter(prefix="/bulk-actions")
2224

@@ -27,6 +29,10 @@ class RecipeBulkActionsController(BaseUserController):
2729
def service(self) -> RecipeBulkActionsService:
2830
return RecipeBulkActionsService(self.repos, self.user, self.group)
2931

32+
@cached_property
33+
def recipe_service(self) -> RecipeService:
34+
return RecipeService(self.repos, self.user, self.household, self.translator)
35+
3036
# TODO Should these actions return some success response?
3137
@router.post("/tag")
3238
def bulk_tag_recipes(self, tag_data: AssignTags):
@@ -42,7 +48,14 @@ def bulk_categorize_recipes(self, assign_cats: AssignCategories):
4248

4349
@router.post("/delete")
4450
def bulk_delete_recipes(self, delete_recipes: DeleteRecipes):
45-
self.service.delete_recipes(delete_recipes.recipes)
51+
# TODO: this route should be migrated to the standard recipe controller
52+
try:
53+
self.recipe_service.delete_many(delete_recipes.recipes)
54+
except PermissionDenied as e:
55+
self.logger.error("Permission Denied on recipe controller action")
56+
raise HTTPException(
57+
status_code=status.HTTP_403_FORBIDDEN, detail=ErrorResponse.respond(message="Permission Denied")
58+
) from e
4659

4760
@router.post("/export", status_code=202)
4861
def bulk_export_recipes(self, export_recipes: ExportRecipes):

mealie/services/recipe/recipe_bulk_service.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,3 @@ def assign_categories(self, recipes: list[str], categories: list[CategoryBase])
110110
except Exception as e:
111111
self.logger.error(f"Failed to categorize recipe {slug}")
112112
self.logger.error(e)
113-
114-
def delete_recipes(self, recipes: list[str]) -> None:
115-
for slug in recipes:
116-
try:
117-
self.repos.recipes.delete(slug)
118-
except Exception as e:
119-
self.logger.error(f"Failed to delete recipe {slug}")
120-
self.logger.error(e)

mealie/services/recipe/recipe_service.py

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
from datetime import UTC, datetime
55
from pathlib import Path
66
from shutil import copytree, rmtree
7+
from textwrap import dedent
78
from typing import Any
89
from uuid import UUID, uuid4
910
from zipfile import ZipFile
1011

12+
import sqlalchemy as sa
1113
from fastapi import UploadFile
1214

1315
from mealie.core import exceptions
@@ -64,31 +66,57 @@ def _get_recipe(self, data: str | UUID, key: str | None = None) -> Recipe:
6466
raise exceptions.NoEntryFound("Recipe not found.")
6567
return recipe
6668

67-
def can_delete(self, recipe: Recipe) -> bool:
69+
def can_delete(self, recipe_slugs: list[str]) -> bool:
6870
if self.user.admin:
6971
return True
7072
else:
71-
return self.can_update(recipe)
72-
73-
def can_update(self, recipe: Recipe) -> bool:
74-
if recipe.settings is None:
75-
raise exceptions.UnexpectedNone("Recipe Settings is None")
76-
77-
# Check if this user owns the recipe
78-
if self.user.id == recipe.user_id:
79-
return True
73+
return self.can_update(recipe_slugs)
74+
75+
def can_update(self, recipe_slugs: list[str]) -> bool:
76+
sql = dedent(
77+
"""
78+
SELECT
79+
CASE
80+
WHEN COUNT(*) = SUM(
81+
CASE
82+
-- User owns the recipe
83+
WHEN r.user_id = :user_id THEN 1
84+
85+
-- Not owner: check if recipe is locked
86+
WHEN COALESCE(rs.locked, TRUE) = TRUE THEN 0
87+
88+
-- Different household: check household policy
89+
WHEN
90+
u.household_id != :household_id
91+
AND COALESCE(hp.lock_recipe_edits_from_other_households, TRUE) = TRUE
92+
THEN 0
93+
94+
-- All other cases: can update
95+
ELSE 1
96+
END
97+
) THEN 1
98+
ELSE 0
99+
END AS all_can_update
100+
FROM recipes r
101+
LEFT JOIN recipe_settings rs ON rs.recipe_id = r.id
102+
LEFT JOIN users u ON u.id = r.user_id
103+
LEFT JOIN households h ON h.id = u.household_id
104+
LEFT JOIN household_preferences hp ON hp.household_id = h.id
105+
WHERE r.slug IN :recipe_slugs AND r.group_id = :group_id;
106+
"""
107+
)
80108

81-
# Check if this user has permission to edit this recipe
82-
if self.household.id != recipe.household_id:
83-
other_household = self.repos.households.get_one(recipe.household_id)
84-
if not (other_household and other_household.preferences):
85-
return False
86-
if other_household.preferences.lock_recipe_edits_from_other_households:
87-
return False
88-
if recipe.settings.locked:
89-
return False
109+
result = self.repos.session.execute(
110+
sa.text(sql).bindparams(sa.bindparam("recipe_slugs", expanding=True)),
111+
params={
112+
"user_id": self.repos.uuid_to_str(self.user.id),
113+
"household_id": self.repos.uuid_to_str(self.household.id),
114+
"group_id": self.repos.uuid_to_str(self.user.group_id),
115+
"recipe_slugs": recipe_slugs,
116+
},
117+
).scalar()
90118

91-
return True
119+
return bool(result)
92120

93121
def can_lock_unlock(self, recipe: Recipe) -> bool:
94122
return recipe.user_id == self.user.id
@@ -423,7 +451,7 @@ def _pre_update_check(self, slug_or_id: str | UUID, new_data: Recipe) -> Recipe:
423451
if recipe is None or recipe.settings is None:
424452
raise exceptions.NoEntryFound("Recipe not found.")
425453

426-
if not self.can_update(recipe):
454+
if not self.can_update([recipe.slug]):
427455
raise exceptions.PermissionDenied("You do not have permission to edit this recipe.")
428456

429457
setting_lock = new_data.settings is not None and recipe.settings.locked != new_data.settings.locked
@@ -444,7 +472,7 @@ def update_one(self, slug_or_id: str | UUID, update_data: Recipe) -> Recipe:
444472

445473
def update_recipe_image(self, slug: str, image: bytes, extension: str):
446474
recipe = self.get_one(slug)
447-
if not self.can_update(recipe):
475+
if not self.can_update([recipe.slug]):
448476
raise exceptions.PermissionDenied("You do not have permission to edit this recipe.")
449477

450478
data_service = RecipeDataService(recipe.id)
@@ -454,7 +482,7 @@ def update_recipe_image(self, slug: str, image: bytes, extension: str):
454482

455483
def delete_recipe_image(self, slug: str) -> None:
456484
recipe = self.get_one(slug)
457-
if not self.can_update(recipe):
485+
if not self.can_update([recipe.slug]):
458486
raise exceptions.PermissionDenied("You do not have permission to edit this recipe.")
459487

460488
data_service = RecipeDataService(recipe.id)
@@ -482,12 +510,24 @@ def update_last_made(self, slug_or_id: str | UUID, timestamp: datetime) -> Recip
482510

483511
def delete_one(self, slug_or_id: str | UUID) -> Recipe:
484512
recipe = self.get_one(slug_or_id)
513+
resp = self.delete_many([recipe.slug])
514+
return resp[0]
485515

486-
if not self.can_delete(recipe):
487-
raise exceptions.PermissionDenied("You do not have permission to delete this recipe.")
516+
def delete_many(self, recipe_slugs: list[str]) -> list[Recipe]:
517+
if not self.can_delete(recipe_slugs):
518+
if len(recipe_slugs) == 1:
519+
msg = "You do not have permission to delete this recipe."
520+
else:
521+
msg = "You do not have permission to delete all of these recipes."
522+
raise exceptions.PermissionDenied(msg)
523+
524+
data = self.group_recipes.delete_many(recipe_slugs)
525+
for r in data:
526+
try:
527+
self.delete_assets(r)
528+
except Exception:
529+
self.logger.exception(f"Failed to delete recipe assets for {r.slug}")
488530

489-
data = self.group_recipes.delete(recipe.id, "id")
490-
self.delete_assets(data)
491531
return data
492532

493533
# =================================================================

tests/integration_tests/user_recipe_tests/test_recipe_owner.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,36 @@ def test_other_user_cant_delete_recipe(api_client: TestClient, user_tuple: list[
160160
assert response.status_code == 403
161161

162162

163+
def test_other_user_bulk_delete(api_client: TestClient, user_tuple: list[TestUser]):
164+
slug_locked = random_string(10)
165+
slug_unlocked = random_string(10)
166+
unique_user, other_user = user_tuple
167+
168+
unique_user.repos.recipes.create(
169+
Recipe(
170+
user_id=unique_user.user_id,
171+
group_id=unique_user.group_id,
172+
name=slug_locked,
173+
settings=RecipeSettings(locked=True),
174+
)
175+
)
176+
unique_user.repos.recipes.create(
177+
Recipe(
178+
user_id=unique_user.user_id,
179+
group_id=unique_user.group_id,
180+
name=slug_unlocked,
181+
settings=RecipeSettings(locked=False),
182+
)
183+
)
184+
185+
response = api_client.post(
186+
api_routes.recipes_bulk_actions_delete,
187+
json={"recipes": [slug_locked, slug_unlocked]},
188+
headers=other_user.token,
189+
)
190+
assert response.status_code == 403
191+
192+
163193
def test_admin_can_delete_locked_recipe_owned_by_another_user(
164194
api_client: TestClient, unfiltered_database: AllRepositories, unique_user: TestUser, admin_user: TestUser
165195
):

0 commit comments

Comments
 (0)