1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-24 07:39:41 +02: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:
Hayden 2021-12-11 19:12:08 -09:00 committed by GitHub
parent ba2d9829bb
commit a2f8f27193
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 202 additions and 21 deletions

View file

@ -22,10 +22,9 @@
<v-spacer></v-spacer> <v-spacer></v-spacer>
<div v-if="!value" class="custom-btn-group ma-1"> <div v-if="!value" class="custom-btn-group ma-1">
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="slug" show-always /> <RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="slug" show-always />
<v-tooltip bottom color="info"> <v-tooltip v-if="!locked" bottom color="info">
<template #activator="{ on, attrs }"> <template #activator="{ on, attrs }">
<v-btn <v-btn
v-if="loggedIn"
fab fab
small small
class="mx-1" class="mx-1"
@ -39,6 +38,22 @@
</template> </template>
<span>{{ $t("general.edit") }}</span> <span>{{ $t("general.edit") }}</span>
</v-tooltip> </v-tooltip>
<v-tooltip v-else bottom color="info">
<template #activator="{ on, attrs }">
<v-btn
fab
small
class="mx-1"
color="info"
v-bind="attrs"
v-on="on"
>
<v-icon> {{ $globals.icons.lock }} </v-icon>
</v-btn>
</template>
<span> Locked by Owner </span>
</v-tooltip>
<RecipeContextMenu <RecipeContextMenu
show-print show-print
:menu-top="false" :menu-top="false"
@ -109,6 +124,10 @@ export default {
required: true, required: true,
type: Number, type: Number,
}, },
locked: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {

View file

@ -23,6 +23,7 @@
v-model="value[key]" v-model="value[key]"
xs xs
dense dense
:disabled="key == 'locked' && !isOwner"
class="my-1" class="my-1"
:label="labels[key]" :label="labels[key]"
hide-details hide-details
@ -41,6 +42,10 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
isOwner: {
type: Boolean,
required: false,
},
}, },
computed: { computed: {
@ -52,6 +57,7 @@ export default {
landscapeView: this.$t("recipe.landscape-view-coming-soon"), landscapeView: this.$t("recipe.landscape-view-coming-soon"),
disableComments: this.$t("recipe.disable-comments"), disableComments: this.$t("recipe.disable-comments"),
disableAmount: this.$t("recipe.disable-amount"), disableAmount: this.$t("recipe.disable-amount"),
locked: "Locked",
}; };
}, },
}, },

View file

@ -10,6 +10,8 @@ export function parseIngredientText(ingredient: RecipeIngredient, disableAmount:
const { quantity, food, unit, note } = ingredient; const { quantity, food, unit, note } = ingredient;
const validQuantity = quantity !== null && quantity !== undefined && quantity !== 0;
let returnQty = ""; let returnQty = "";
if (unit?.fraction) { if (unit?.fraction) {
const fraction = frac(quantity * scale, 10, true); const fraction = frac(quantity * scale, 10, true);
@ -20,8 +22,10 @@ export function parseIngredientText(ingredient: RecipeIngredient, disableAmount:
if (fraction[1] > 0) { if (fraction[1] > 0) {
returnQty += ` <sup>${fraction[1]}</sup>&frasl;<sub>${fraction[2]}</sub>`; returnQty += ` <sup>${fraction[1]}</sup>&frasl;<sub>${fraction[2]}</sub>`;
} }
} else { } else if (validQuantity) {
returnQty = (quantity * scale).toString(); returnQty = (quantity * scale).toString();
} else {
returnQty = "";
} }
return `${returnQty} ${unit?.name || " "} ${food?.name || " "} ${note}`.replace(/ {2,}/g, " "); return `${returnQty} ${unit?.name || " "} ${food?.name || " "} ${note}`.replace(/ {2,}/g, " ");

View file

@ -51,6 +51,7 @@
<RecipeActionMenu <RecipeActionMenu
v-model="form" v-model="form"
:slug="recipe.slug" :slug="recipe.slug"
:locked="$auth.user.id !== recipe.userId && recipe.settings.locked"
:name="recipe.name" :name="recipe.name"
:logged-in="$auth.loggedIn" :logged-in="$auth.loggedIn"
:open="form" :open="form"
@ -77,7 +78,7 @@
> >
<div v-if="form" class="d-flex justify-start align-center"> <div v-if="form" class="d-flex justify-start align-center">
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" /> <RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" />
<RecipeSettingsMenu class="my-1 mx-1" :value="recipe.settings" @upload="uploadImage" /> <RecipeSettingsMenu class="my-1 mx-1" :value="recipe.settings" :is-owner="recipe.userId == $auth.user.id" @upload="uploadImage" />
</div> </div>
<!-- Recipe Title Section --> <!-- Recipe Title Section -->
<template v-if="!form && enableLandscape"> <template v-if="!form && enableLandscape">

View file

@ -1,6 +1,7 @@
from __future__ import annotations 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 import func
from sqlalchemy.orm import load_only from sqlalchemy.orm import load_only
@ -29,9 +30,36 @@ class AccessModel(Generic[T, D]):
self.schema = schema self.schema = schema
self.observers: list = [] 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: def subscribe(self, func: Callable) -> None:
self.observers.append(func) 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 # TODO: Run Observer in Async Background Task
def update_observers(self) -> None: def update_observers(self) -> None:
if self.observers: if self.observers:
@ -114,16 +142,21 @@ class AccessModel(Generic[T, D]):
if match_key is None: if match_key is None:
match_key = self.primary_key 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: def get_one(self, value: str | int, key: str = None, any_case=False, override_schema=None) -> T:
key = key or self.primary_key key = key or self.primary_key
q = self.session.query(self.sql_model)
if any_case: if any_case:
search_attr = getattr(self.sql_model, key) 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: 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: if not result:
return return
@ -255,7 +288,11 @@ class AccessModel(Generic[T, D]):
return self.session.query(self.sql_model).filter_by(**{match_key: match_value}).count() return self.session.query(self.sql_model).filter_by(**{match_key: match_value}).count()
def _count_attribute( 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]: ) -> Union[int, T]:
eff_schema = override_schema or self.schema eff_schema = override_schema or self.schema
# attr_filter = getattr(self.sql_model, attribute_name) # attr_filter = getattr(self.sql_model, attribute_name)

View file

@ -13,6 +13,7 @@ class RecipeSettings(SqlAlchemyBase):
landscape_view = sa.Column(sa.Boolean) landscape_view = sa.Column(sa.Boolean)
disable_amount = sa.Column(sa.Boolean, default=False) disable_amount = sa.Column(sa.Boolean, default=False)
disable_comments = sa.Column(sa.Boolean, default=False) disable_comments = sa.Column(sa.Boolean, default=False)
locked = sa.Column(sa.Boolean, default=False)
def __init__( def __init__(
self, self,
@ -22,7 +23,9 @@ class RecipeSettings(SqlAlchemyBase):
landscape_view=True, landscape_view=True,
disable_amount=True, disable_amount=True,
disable_comments=False, disable_comments=False,
locked=False,
) -> None: ) -> None:
self.locked = locked
self.public = public self.public = public
self.show_nutrition = show_nutrition self.show_nutrition = show_nutrition
self.show_assets = show_assets self.show_assets = show_assets

View file

@ -1,9 +1,5 @@
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from mealie.core.config import get_app_settings
settings = get_app_settings()
class RecipeSettings(CamelModel): class RecipeSettings(CamelModel):
public: bool = False public: bool = False
@ -12,6 +8,7 @@ class RecipeSettings(CamelModel):
landscape_view: bool = False landscape_view: bool = False
disable_comments: bool = True disable_comments: bool = True
disable_amount: bool = True disable_amount: bool = True
locked: bool = False
class Config: class Config:
orm_mode = True orm_mode = True

View file

@ -109,7 +109,7 @@ class BaseHttpService(Generic[T, D], ABC):
def group_id(self): def group_id(self):
# TODO: Populate Group in Private User Call WARNING: May require significant refactoring # TODO: Populate Group in Private User Call WARNING: May require significant refactoring
if not self._group_id_cache: 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 self._group_id_cache = group.id
return self._group_id_cache return self._group_id_cache

View file

@ -9,7 +9,7 @@ from zipfile import ZipFile
from fastapi import Depends, HTTPException, UploadFile, status from fastapi import Depends, HTTPException, UploadFile, status
from sqlalchemy import exc 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.core.root_logger import get_logger
from mealie.db.data_access_layer.recipe_access_model import RecipeDataAccessModel from mealie.db.data_access_layer.recipe_access_model import RecipeDataAccessModel
from mealie.schema.recipe.recipe import CreateRecipe, Recipe, RecipeSummary from mealie.schema.recipe.recipe import CreateRecipe, Recipe, RecipeSummary
@ -41,14 +41,14 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
@cached_property @cached_property
def dal(self) -> RecipeDataAccessModel: def dal(self) -> RecipeDataAccessModel:
return self.db.recipes return self.db.recipes.by_group(self.group_id)
@classmethod @classmethod
def write_existing(cls, slug: str, deps: UserDeps = Depends()): def write_existing(cls, slug: str, deps: UserDeps = Depends()):
return super().write_existing(slug, deps) return super().write_existing(slug, deps)
@classmethod @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) return super().write_existing(slug, deps)
def assert_existing(self, slug: str): 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: if not self.item.settings.public and not self.user:
raise HTTPException(status.HTTP_403_FORBIDDEN) 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]: 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) 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: def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
group = self.db.groups.get(self.group_id, "id") group = self.db.groups.get(self.group_id, "id")
create_data = recipe_creation_factory( create_data: Recipe = recipe_creation_factory(
self.user, self.user,
name=create_data.name, name=create_data.name,
additional_attrs=create_data.dict(), additional_attrs=create_data.dict(),
@ -129,18 +135,28 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
return self.item return self.item
def update_one(self, update_data: Recipe) -> Recipe: 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 original_slug = self.item.slug
self._update_one(update_data, original_slug) self._update_one(update_data, original_slug)
self.check_assets(original_slug) self.check_assets(original_slug)
return self.item return self.item
def patch_one(self, patch_data: Recipe) -> Recipe: def patch_one(self, patch_data: Recipe) -> Recipe:
self.can_update()
original_slug = self.item.slug original_slug = self.item.slug
self._patch_one(patch_data, original_slug) self._patch_one(patch_data, original_slug)
self.check_assets(original_slug) self.check_assets(original_slug)
return self.item return self.item
def delete_one(self) -> Recipe: def delete_one(self) -> Recipe:
self.can_update()
self._delete_one(self.item.slug) self._delete_one(self.item.slug)
self.delete_assets() self.delete_assets()
self._create_event("Recipe Delete", f"'{self.item.name}' deleted by {self.user.full_name}") self._create_event("Recipe Delete", f"'{self.item.name}' deleted by {self.user.full_name}")

View file

@ -1,4 +1,5 @@
import json import json
from typing import Tuple
import requests import requests
from pytest import fixture 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") group_id = json.loads(self_response.text).get("groupId")
try: 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: finally:
# TODO: Delete User after test # TODO: Delete User after test
pass pass
@ -69,6 +75,59 @@ def unique_user(api_client: TestClient, api_routes: utils.AppRoutes):
pass 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") @fixture(scope="session")
def user_token(admin_token, api_client: requests, api_routes: utils.AppRoutes): def user_token(admin_token, api_client: requests, api_routes: utils.AppRoutes):
# Create the user # Create the user

View file

@ -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): def test_ownership_on_new_with_user(api_client: TestClient, g2_user: TestUser):
recipe_name = random_string() recipe_name = random_string()
response = api_client.post(Routes.base, json={"name": recipe_name}, headers=g2_user.token) response = api_client.post(Routes.base, json={"name": recipe_name}, headers=g2_user.token)
assert response.status_code == 201 assert response.status_code == 201
response = api_client.get(Routes.base + f"/{recipe_name}", headers=g2_user.token) 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 # Try to create a recipe again with the same name
response = api_client.post(Routes.base, json=create_data, headers=g2_user.token) response = api_client.post(Routes.base, json=create_data, headers=g2_user.token)
assert response.status_code == 400 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