mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-01-02 23:21:21 -05:00
feat: ✨ support for lockable recipes (#876)
* feat: ✨ support for lockable recipes * feat(backend): ✨ check user can update before updating recipe * test(backend): ✅ add recipe lock tests * feat(frontend): ✨ disabled lock action when not owner * test(backend): ✅ test non-owner can't lock recipe * hide quantity on zero value * fix(backend): 🐛 temp/partial fix for recipes with same name. WIP
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Generic, TypeVar, Union
|
||||
from typing import Any, Callable, Generic, TypeVar, Union
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import load_only
|
||||
@@ -29,9 +30,36 @@ class AccessModel(Generic[T, D]):
|
||||
self.schema = schema
|
||||
self.observers: list = []
|
||||
|
||||
self.limit_by_group = False
|
||||
self.user_id = None
|
||||
|
||||
self.limit_by_user = False
|
||||
self.group_id = None
|
||||
|
||||
def subscribe(self, func: Callable) -> None:
|
||||
self.observers.append(func)
|
||||
|
||||
def by_user(self, user_id: int) -> AccessModel:
|
||||
self.limit_by_user = True
|
||||
self.user_id = user_id
|
||||
return self
|
||||
|
||||
def by_group(self, group_id: UUID) -> AccessModel:
|
||||
self.limit_by_group = True
|
||||
self.group_id = group_id
|
||||
return self
|
||||
|
||||
def _filter_builder(self, **kwargs) -> dict[str, Any]:
|
||||
dct = {}
|
||||
|
||||
if self.limit_by_user:
|
||||
dct["user_id"] = self.user_id
|
||||
|
||||
if self.limit_by_group:
|
||||
dct["group_id"] = self.group_id
|
||||
|
||||
return {**dct, **kwargs}
|
||||
|
||||
# TODO: Run Observer in Async Background Task
|
||||
def update_observers(self) -> None:
|
||||
if self.observers:
|
||||
@@ -114,16 +142,21 @@ class AccessModel(Generic[T, D]):
|
||||
if match_key is None:
|
||||
match_key = self.primary_key
|
||||
|
||||
return self.session.query(self.sql_model).filter_by(**{match_key: match_value}).one()
|
||||
filter = self._filter_builder(**{match_key: match_value})
|
||||
return self.session.query(self.sql_model).filter_by(**filter).one()
|
||||
|
||||
def get_one(self, value: str | int, key: str = None, any_case=False, override_schema=None) -> T:
|
||||
key = key or self.primary_key
|
||||
|
||||
q = self.session.query(self.sql_model)
|
||||
|
||||
if any_case:
|
||||
search_attr = getattr(self.sql_model, key)
|
||||
result = self.session.query(self.sql_model).filter(func.lower(search_attr) == key.lower()).one_or_none()
|
||||
q = q.filter(func.lower(search_attr) == key.lower()).filter_by(**self._filter_builder())
|
||||
else:
|
||||
result = self.session.query(self.sql_model).filter_by(**{key: value}).one_or_none()
|
||||
q = self.session.query(self.sql_model).filter_by(**self._filter_builder(**{key: value}))
|
||||
|
||||
result = q.one_or_none()
|
||||
|
||||
if not result:
|
||||
return
|
||||
@@ -255,7 +288,11 @@ class AccessModel(Generic[T, D]):
|
||||
return self.session.query(self.sql_model).filter_by(**{match_key: match_value}).count()
|
||||
|
||||
def _count_attribute(
|
||||
self, attribute_name: str, attr_match: str = None, count=True, override_schema=None
|
||||
self,
|
||||
attribute_name: str,
|
||||
attr_match: str = None,
|
||||
count=True,
|
||||
override_schema=None,
|
||||
) -> Union[int, T]:
|
||||
eff_schema = override_schema or self.schema
|
||||
# attr_filter = getattr(self.sql_model, attribute_name)
|
||||
|
||||
@@ -13,6 +13,7 @@ class RecipeSettings(SqlAlchemyBase):
|
||||
landscape_view = sa.Column(sa.Boolean)
|
||||
disable_amount = sa.Column(sa.Boolean, default=False)
|
||||
disable_comments = sa.Column(sa.Boolean, default=False)
|
||||
locked = sa.Column(sa.Boolean, default=False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -22,7 +23,9 @@ class RecipeSettings(SqlAlchemyBase):
|
||||
landscape_view=True,
|
||||
disable_amount=True,
|
||||
disable_comments=False,
|
||||
locked=False,
|
||||
) -> None:
|
||||
self.locked = locked
|
||||
self.public = public
|
||||
self.show_nutrition = show_nutrition
|
||||
self.show_assets = show_assets
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
from fastapi_camelcase import CamelModel
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
|
||||
settings = get_app_settings()
|
||||
|
||||
|
||||
class RecipeSettings(CamelModel):
|
||||
public: bool = False
|
||||
@@ -12,6 +8,7 @@ class RecipeSettings(CamelModel):
|
||||
landscape_view: bool = False
|
||||
disable_comments: bool = True
|
||||
disable_amount: bool = True
|
||||
locked: bool = False
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@@ -109,7 +109,7 @@ class BaseHttpService(Generic[T, D], ABC):
|
||||
def group_id(self):
|
||||
# TODO: Populate Group in Private User Call WARNING: May require significant refactoring
|
||||
if not self._group_id_cache:
|
||||
group = self.db.groups.get(self.user.group, "name")
|
||||
group = self.db.groups.get_one(self.user.group, "name")
|
||||
self._group_id_cache = group.id
|
||||
return self._group_id_cache
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from zipfile import ZipFile
|
||||
from fastapi import Depends, HTTPException, UploadFile, status
|
||||
from sqlalchemy import exc
|
||||
|
||||
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
|
||||
from mealie.core.dependencies.grouped import UserDeps
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.db.data_access_layer.recipe_access_model import RecipeDataAccessModel
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, Recipe, RecipeSummary
|
||||
@@ -41,14 +41,14 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
|
||||
|
||||
@cached_property
|
||||
def dal(self) -> RecipeDataAccessModel:
|
||||
return self.db.recipes
|
||||
return self.db.recipes.by_group(self.group_id)
|
||||
|
||||
@classmethod
|
||||
def write_existing(cls, slug: str, deps: UserDeps = Depends()):
|
||||
return super().write_existing(slug, deps)
|
||||
|
||||
@classmethod
|
||||
def read_existing(cls, slug: str, deps: PublicDeps = Depends()):
|
||||
def read_existing(cls, slug: str, deps: UserDeps = Depends()):
|
||||
return super().write_existing(slug, deps)
|
||||
|
||||
def assert_existing(self, slug: str):
|
||||
@@ -59,6 +59,12 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
|
||||
if not self.item.settings.public and not self.user:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def can_update(self) -> bool:
|
||||
if self.user.id == self.item.user_id:
|
||||
return True
|
||||
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def get_all(self, start=0, limit=None, load_foods=False) -> list[RecipeSummary]:
|
||||
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit, load_foods=load_foods)
|
||||
|
||||
@@ -78,7 +84,7 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
|
||||
def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
|
||||
group = self.db.groups.get(self.group_id, "id")
|
||||
|
||||
create_data = recipe_creation_factory(
|
||||
create_data: Recipe = recipe_creation_factory(
|
||||
self.user,
|
||||
name=create_data.name,
|
||||
additional_attrs=create_data.dict(),
|
||||
@@ -129,18 +135,28 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
|
||||
return self.item
|
||||
|
||||
def update_one(self, update_data: Recipe) -> Recipe:
|
||||
self.can_update()
|
||||
|
||||
if self.item.settings.locked != update_data.settings.locked and self.item.user_id != self.user.id:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN)
|
||||
|
||||
original_slug = self.item.slug
|
||||
self._update_one(update_data, original_slug)
|
||||
|
||||
self.check_assets(original_slug)
|
||||
return self.item
|
||||
|
||||
def patch_one(self, patch_data: Recipe) -> Recipe:
|
||||
self.can_update()
|
||||
|
||||
original_slug = self.item.slug
|
||||
self._patch_one(patch_data, original_slug)
|
||||
|
||||
self.check_assets(original_slug)
|
||||
return self.item
|
||||
|
||||
def delete_one(self) -> Recipe:
|
||||
self.can_update()
|
||||
self._delete_one(self.item.slug)
|
||||
self.delete_assets()
|
||||
self._create_event("Recipe Delete", f"'{self.item.name}' deleted by {self.user.full_name}")
|
||||
|
||||
Reference in New Issue
Block a user