diff --git a/frontend/components/Domain/Recipe/RecipeActionMenu.vue b/frontend/components/Domain/Recipe/RecipeActionMenu.vue
index cd9e6123e..f68c2785f 100644
--- a/frontend/components/Domain/Recipe/RecipeActionMenu.vue
+++ b/frontend/components/Domain/Recipe/RecipeActionMenu.vue
@@ -22,10 +22,9 @@
-
+
{{ $t("general.edit") }}
+
+
+
+ {{ $globals.icons.lock }}
+
+
+ Locked by Owner
+
+
0) {
returnQty += ` ${fraction[1]}⁄${fraction[2]}`;
}
- } else {
+ } else if (validQuantity) {
returnQty = (quantity * scale).toString();
+ } else {
+ returnQty = "";
}
return `${returnQty} ${unit?.name || " "} ${food?.name || " "} ${note}`.replace(/ {2,}/g, " ");
diff --git a/frontend/pages/recipe/_slug/index.vue b/frontend/pages/recipe/_slug/index.vue
index 2628f8b43..f20160464 100644
--- a/frontend/pages/recipe/_slug/index.vue
+++ b/frontend/pages/recipe/_slug/index.vue
@@ -51,6 +51,7 @@
-
+
diff --git a/mealie/db/data_access_layer/_access_model.py b/mealie/db/data_access_layer/_access_model.py
index 83fba27bd..f658d09cf 100644
--- a/mealie/db/data_access_layer/_access_model.py
+++ b/mealie/db/data_access_layer/_access_model.py
@@ -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)
diff --git a/mealie/db/models/recipe/settings.py b/mealie/db/models/recipe/settings.py
index b7f4ecdc7..a3954e061 100644
--- a/mealie/db/models/recipe/settings.py
+++ b/mealie/db/models/recipe/settings.py
@@ -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
diff --git a/mealie/schema/recipe/recipe_settings.py b/mealie/schema/recipe/recipe_settings.py
index 77fe07c39..4c0e5790c 100644
--- a/mealie/schema/recipe/recipe_settings.py
+++ b/mealie/schema/recipe/recipe_settings.py
@@ -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
diff --git a/mealie/services/_base_http_service/base_http_service.py b/mealie/services/_base_http_service/base_http_service.py
index 6a7e7514f..a5d686890 100644
--- a/mealie/services/_base_http_service/base_http_service.py
+++ b/mealie/services/_base_http_service/base_http_service.py
@@ -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
diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py
index 03fe9fd81..8e43cda7d 100644
--- a/mealie/services/recipe/recipe_service.py
+++ b/mealie/services/recipe/recipe_service.py
@@ -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}")
diff --git a/tests/fixtures/fixture_users.py b/tests/fixtures/fixture_users.py
index 0ef7ed557..485dcd23a 100644
--- a/tests/fixtures/fixture_users.py
+++ b/tests/fixtures/fixture_users.py
@@ -1,4 +1,5 @@
import json
+from typing import Tuple
import requests
from pytest import fixture
@@ -38,7 +39,12 @@ def g2_user(admin_token, api_client: requests, api_routes: utils.AppRoutes):
group_id = json.loads(self_response.text).get("groupId")
try:
- yield utils.TestUser(user_id=user_id, _group_id=group_id, token=token, email=create_data["email"])
+ yield utils.TestUser(
+ user_id=user_id,
+ _group_id=group_id,
+ token=token,
+ email=create_data["email"],
+ )
finally:
# TODO: Delete User after test
pass
@@ -69,6 +75,59 @@ def unique_user(api_client: TestClient, api_routes: utils.AppRoutes):
pass
+@fixture(scope="module")
+def user_tuple(admin_token, api_client: requests, api_routes: utils.AppRoutes) -> Tuple[utils.TestUser]:
+ group_name = utils.random_string()
+ # Create the user
+ create_data_1 = {
+ "fullName": utils.random_string(),
+ "username": utils.random_string(),
+ "email": utils.random_email(),
+ "password": "useruser",
+ "group": group_name,
+ "admin": False,
+ "tokens": [],
+ }
+
+ create_data_2 = {
+ "fullName": utils.random_string(),
+ "username": utils.random_string(),
+ "email": utils.random_email(),
+ "password": "useruser",
+ "group": group_name,
+ "admin": False,
+ "tokens": [],
+ }
+
+ users_out = []
+
+ for usr in [create_data_1, create_data_2]:
+ response = api_client.post(api_routes.groups, json={"name": "New Group"}, headers=admin_token)
+ response = api_client.post(api_routes.users, json=usr, headers=admin_token)
+ assert response.status_code == 201
+
+ # Log in as this user
+ form_data = {"username": usr["email"], "password": "useruser"}
+ token = utils.login(form_data, api_client, api_routes)
+ response = api_client.get(api_routes.users_self, headers=token)
+ assert response.status_code == 200
+ user_data = json.loads(response.text)
+
+ users_out.append(
+ utils.TestUser(
+ _group_id=user_data.get("groupId"),
+ user_id=user_data.get("id"),
+ email=user_data.get("email"),
+ token=token,
+ )
+ )
+
+ try:
+ yield users_out
+ finally:
+ pass
+
+
@fixture(scope="session")
def user_token(admin_token, api_client: requests, api_routes: utils.AppRoutes):
# Create the user
diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py
index 6bbe71395..67af89a8b 100644
--- a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py
+++ b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py
@@ -22,9 +22,7 @@ def test_ownership_on_new_with_admin(api_client: TestClient, admin_user: TestUse
def test_ownership_on_new_with_user(api_client: TestClient, g2_user: TestUser):
recipe_name = random_string()
-
response = api_client.post(Routes.base, json={"name": recipe_name}, headers=g2_user.token)
-
assert response.status_code == 201
response = api_client.get(Routes.base + f"/{recipe_name}", headers=g2_user.token)
@@ -67,3 +65,44 @@ def test_unique_slug_by_group(api_client: TestClient, unique_user: TestUser, g2_
# Try to create a recipe again with the same name
response = api_client.post(Routes.base, json=create_data, headers=g2_user.token)
assert response.status_code == 400
+
+
+def test_user_locked_recipe(api_client: TestClient, user_tuple: list[TestUser]) -> None:
+ usr_1, usr_2 = user_tuple
+
+ # Setup Recipe
+ recipe_name = random_string()
+ response = api_client.post(Routes.base, json={"name": recipe_name}, headers=usr_1.token)
+ assert response.status_code == 201
+
+ # Get Recipe
+ response = api_client.get(Routes.base + f"/{recipe_name}", headers=usr_1.token)
+ assert response.status_code == 200
+ recipe = response.json()
+
+ # Lock Recipe
+ recipe["settings"]["locked"] = True
+ response = api_client.put(Routes.base + f"/{recipe_name}", json=recipe, headers=usr_1.token)
+
+ # Try To Update Recipe with User 2
+ response = api_client.put(Routes.base + f"/{recipe_name}", json=recipe, headers=usr_2.token)
+ assert response.status_code == 403
+
+
+def test_other_user_cant_lock_recipe(api_client: TestClient, user_tuple: list[TestUser]) -> None:
+ usr_1, usr_2 = user_tuple
+
+ # Setup Recipe
+ recipe_name = random_string()
+ response = api_client.post(Routes.base, json={"name": recipe_name}, headers=usr_1.token)
+ assert response.status_code == 201
+
+ # Get Recipe
+ response = api_client.get(Routes.base + f"/{recipe_name}", headers=usr_2.token)
+ assert response.status_code == 200
+ recipe = response.json()
+
+ # Lock Recipe
+ recipe["settings"]["locked"] = True
+ response = api_client.put(Routes.base + f"/{recipe_name}", json=recipe, headers=usr_2.token)
+ assert response.status_code == 403