feat: Additional Household Permissions (#4158)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson
2024-09-17 10:48:14 -05:00
committed by GitHub
parent b1820f9b23
commit fd0257c1b8
37 changed files with 690 additions and 185 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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