mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-14 17:05:40 -04:00
feat: Additional Household Permissions (#4158)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
@@ -22,6 +22,7 @@ class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins):
|
||||
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
|
||||
|
||||
private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||
lock_recipe_edits_from_other_households: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
|
||||
|
||||
# Recipe Defaults
|
||||
|
||||
@@ -68,6 +68,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
|
||||
# Group Permissions
|
||||
can_manage_household: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
can_manage: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
can_invite: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
can_organize: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
@@ -108,6 +109,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
exclude={
|
||||
"password",
|
||||
"admin",
|
||||
"can_manage_household",
|
||||
"can_manage",
|
||||
"can_invite",
|
||||
"can_organize",
|
||||
@@ -186,22 +188,27 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
def update_password(self, password):
|
||||
self.password = password
|
||||
|
||||
def _set_permissions(self, admin, can_manage=False, can_invite=False, can_organize=False, **_):
|
||||
def _set_permissions(
|
||||
self, admin, can_manage_household=False, can_manage=False, can_invite=False, can_organize=False, **_
|
||||
):
|
||||
"""Set user permissions based on the admin flag and the passed in kwargs
|
||||
|
||||
Args:
|
||||
admin (bool):
|
||||
can_manage_household (bool):
|
||||
can_manage (bool):
|
||||
can_invite (bool):
|
||||
can_organize (bool):
|
||||
"""
|
||||
self.admin = admin
|
||||
if self.admin:
|
||||
self.can_manage_household = True
|
||||
self.can_manage = True
|
||||
self.can_invite = True
|
||||
self.can_organize = True
|
||||
self.advanced = True
|
||||
else:
|
||||
self.can_manage_household = can_manage_household
|
||||
self.can_manage = can_manage
|
||||
self.can_invite = can_invite
|
||||
self.can_organize = can_organize
|
||||
|
||||
@@ -20,6 +20,11 @@ class OperationChecks:
|
||||
# =========================================
|
||||
# User Permission Checks
|
||||
|
||||
def can_manage_household(self) -> bool:
|
||||
if not self.user.can_manage_household:
|
||||
raise self.ForbiddenException
|
||||
return True
|
||||
|
||||
def can_manage(self) -> bool:
|
||||
if not self.user.can_manage:
|
||||
raise self.ForbiddenException
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import Query
|
||||
from fastapi import HTTPException, Query
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base.base_controllers import BaseUserController
|
||||
@@ -10,6 +10,7 @@ from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGr
|
||||
from mealie.schema.group.group_statistics import GroupStorage
|
||||
from mealie.schema.household.household import HouseholdSummary
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.schema.response.responses import ErrorResponse
|
||||
from mealie.schema.user.user import GroupSummary, UserSummary
|
||||
from mealie.services.group_services.group_service import GroupService
|
||||
|
||||
@@ -42,6 +43,16 @@ class GroupSelfServiceController(BaseUserController):
|
||||
households = self.repos.households.page_all(PaginationQuery(page=1, per_page=-1)).items
|
||||
return [household.cast(HouseholdSummary) for household in households]
|
||||
|
||||
@router.get("/households/{slug}", response_model=HouseholdSummary)
|
||||
def get_group_household(self, slug: str):
|
||||
"""Returns a single household belonging to the current group"""
|
||||
|
||||
household = self.repos.households.get_by_slug_or_id(slug)
|
||||
if not household:
|
||||
raise HTTPException(status_code=404, detail=ErrorResponse.respond(message="No Entry Found"))
|
||||
|
||||
return household.cast(HouseholdSummary)
|
||||
|
||||
@router.get("/preferences", response_model=ReadGroupPreferences)
|
||||
def get_group_preferences(self):
|
||||
return self.group.preferences
|
||||
|
||||
@@ -41,6 +41,8 @@ class HouseholdSelfServiceController(BaseUserController):
|
||||
|
||||
@router.put("/preferences", response_model=ReadHouseholdPreferences)
|
||||
def update_household_preferences(self, new_pref: UpdateHouseholdPreferences):
|
||||
self.checks.can_manage_household()
|
||||
|
||||
return self.repos.household_preferences.update(self.household_id, new_pref)
|
||||
|
||||
@router.put("/permissions", response_model=UserOut)
|
||||
@@ -60,6 +62,7 @@ class HouseholdSelfServiceController(BaseUserController):
|
||||
|
||||
target_user.can_invite = permissions.can_invite
|
||||
target_user.can_manage = permissions.can_manage
|
||||
target_user.can_manage_household = permissions.can_manage_household
|
||||
target_user.can_organize = permissions.can_organize
|
||||
|
||||
return self.repos.users.update(permissions.user_id, target_user)
|
||||
|
||||
@@ -5,6 +5,7 @@ from mealie.schema._mealie import MealieModel
|
||||
|
||||
class SetPermissions(MealieModel):
|
||||
user_id: UUID4
|
||||
can_manage_household: bool = False
|
||||
can_manage: bool = False
|
||||
can_invite: bool = False
|
||||
can_organize: bool = False
|
||||
|
||||
@@ -5,6 +5,7 @@ from mealie.schema._mealie import MealieModel
|
||||
|
||||
class UpdateHouseholdPreferences(MealieModel):
|
||||
private_household: bool = True
|
||||
lock_recipe_edits_from_other_households: bool = True
|
||||
first_day_of_week: int = 0
|
||||
|
||||
# Recipe Defaults
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
# This file is auto-generated by gen_schema_exports.py
|
||||
from .recipe import OpenAIRecipe, OpenAIRecipeIngredient, OpenAIRecipeInstruction, OpenAIRecipeNotes
|
||||
from .recipe_ingredient import OpenAIIngredient, OpenAIIngredients
|
||||
|
||||
__all__ = [
|
||||
"OpenAIIngredient",
|
||||
"OpenAIIngredients",
|
||||
"OpenAIRecipe",
|
||||
"OpenAIRecipeIngredient",
|
||||
"OpenAIRecipeInstruction",
|
||||
"OpenAIRecipeNotes",
|
||||
]
|
||||
|
||||
@@ -106,6 +106,7 @@ class UserBase(MealieModel):
|
||||
|
||||
can_invite: bool = False
|
||||
can_manage: bool = False
|
||||
can_manage_household: bool = False
|
||||
can_organize: bool = False
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
|
||||
@@ -73,7 +73,11 @@ class RecipeService(RecipeServiceBase):
|
||||
|
||||
# Check if this user has permission to edit this recipe
|
||||
if self.household.id != recipe.household_id:
|
||||
return False
|
||||
other_household = self.repos.households.get_one(recipe.household_id)
|
||||
if not (other_household and other_household.preferences):
|
||||
return False
|
||||
if other_household.preferences.lock_recipe_edits_from_other_households:
|
||||
return False
|
||||
if recipe.settings.locked:
|
||||
return False
|
||||
|
||||
@@ -135,7 +139,7 @@ class RecipeService(RecipeServiceBase):
|
||||
|
||||
return Recipe(**additional_attrs)
|
||||
|
||||
def get_one(self, slug_or_id: str | UUID) -> Recipe | None:
|
||||
def get_one(self, slug_or_id: str | UUID) -> Recipe:
|
||||
if isinstance(slug_or_id, str):
|
||||
try:
|
||||
slug_or_id = UUID(slug_or_id)
|
||||
@@ -301,10 +305,10 @@ class RecipeService(RecipeServiceBase):
|
||||
data_service.write_image(f.read(), "webp")
|
||||
return recipe
|
||||
|
||||
def duplicate_one(self, old_slug: str, dup_data: RecipeDuplicate) -> Recipe:
|
||||
def duplicate_one(self, old_slug_or_id: str | UUID, dup_data: RecipeDuplicate) -> Recipe:
|
||||
"""Duplicates a recipe and returns the new recipe."""
|
||||
|
||||
old_recipe = self._get_recipe(old_slug)
|
||||
old_recipe = self.get_one(old_slug_or_id)
|
||||
new_recipe_data = old_recipe.model_dump(exclude={"id", "name", "slug", "image", "comments"}, round_trip=True)
|
||||
new_recipe = Recipe.model_validate(new_recipe_data)
|
||||
|
||||
@@ -356,7 +360,7 @@ class RecipeService(RecipeServiceBase):
|
||||
|
||||
return new_recipe
|
||||
|
||||
def _pre_update_check(self, slug: str, new_data: Recipe) -> Recipe:
|
||||
def _pre_update_check(self, slug_or_id: str | UUID, new_data: Recipe) -> Recipe:
|
||||
"""
|
||||
gets the recipe from the database and performs a check to see if the user can update the recipe.
|
||||
If the user can't update the recipe, an exception is raised.
|
||||
@@ -367,14 +371,14 @@ class RecipeService(RecipeServiceBase):
|
||||
- _if_ the user is locking the recipe, that they can lock the recipe (user is the owner)
|
||||
|
||||
Args:
|
||||
slug (str): recipe slug
|
||||
slug_or_id (str | UUID): recipe slug or id
|
||||
new_data (Recipe): the new recipe data
|
||||
|
||||
Raises:
|
||||
exceptions.PermissionDenied (403)
|
||||
"""
|
||||
|
||||
recipe = self._get_recipe(slug)
|
||||
recipe = self.get_one(slug_or_id)
|
||||
|
||||
if recipe is None or recipe.settings is None:
|
||||
raise exceptions.NoEntryFound("Recipe not found.")
|
||||
@@ -388,38 +392,35 @@ class RecipeService(RecipeServiceBase):
|
||||
|
||||
return recipe
|
||||
|
||||
def update_one(self, slug: str, update_data: Recipe) -> Recipe:
|
||||
recipe = self._pre_update_check(slug, update_data)
|
||||
def update_one(self, slug_or_id: str | UUID, update_data: Recipe) -> Recipe:
|
||||
recipe = self._pre_update_check(slug_or_id, update_data)
|
||||
|
||||
new_data = self.repos.recipes.update(slug, update_data)
|
||||
new_data = self.group_recipes.update(recipe.slug, update_data)
|
||||
self.check_assets(new_data, recipe.slug)
|
||||
return new_data
|
||||
|
||||
def patch_one(self, slug: str, patch_data: Recipe) -> Recipe:
|
||||
recipe: Recipe | None = self._pre_update_check(slug, patch_data)
|
||||
recipe = self._get_recipe(slug)
|
||||
def patch_one(self, slug_or_id: str | UUID, patch_data: Recipe) -> Recipe:
|
||||
recipe: Recipe | None = self._pre_update_check(slug_or_id, patch_data)
|
||||
recipe = self.get_one(slug_or_id)
|
||||
|
||||
if recipe is None:
|
||||
raise exceptions.NoEntryFound("Recipe not found.")
|
||||
|
||||
new_data = self.repos.recipes.patch(recipe.slug, patch_data.model_dump(exclude_unset=True))
|
||||
new_data = self.group_recipes.patch(recipe.slug, patch_data.model_dump(exclude_unset=True))
|
||||
|
||||
self.check_assets(new_data, recipe.slug)
|
||||
return new_data
|
||||
|
||||
def update_last_made(self, slug: str, timestamp: datetime) -> Recipe:
|
||||
def update_last_made(self, slug_or_id: str | UUID, timestamp: datetime) -> Recipe:
|
||||
# we bypass the pre update check since any user can update a recipe's last made date, even if it's locked,
|
||||
# or if the user belongs to a different household
|
||||
recipe = self._get_recipe(slug)
|
||||
recipe = self.get_one(slug_or_id)
|
||||
return self.group_recipes.patch(recipe.slug, {"last_made": timestamp})
|
||||
|
||||
def delete_one(self, slug) -> Recipe:
|
||||
recipe = self._get_recipe(slug)
|
||||
def delete_one(self, slug_or_id: str | UUID) -> Recipe:
|
||||
recipe = self.get_one(slug_or_id)
|
||||
|
||||
if not self.can_update(recipe):
|
||||
raise exceptions.PermissionDenied("You do not have permission to delete this recipe.")
|
||||
|
||||
data = self.repos.recipes.delete(recipe.id, "id")
|
||||
data = self.group_recipes.delete(recipe.id, "id")
|
||||
self.delete_assets(data)
|
||||
return data
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class RegistrationService:
|
||||
household=household,
|
||||
can_invite=new_group,
|
||||
can_manage=new_group,
|
||||
can_manage_household=new_group,
|
||||
can_organize=new_group,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user