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:
parent
ba2d9829bb
commit
a2f8f27193
11 changed files with 202 additions and 21 deletions
|
@ -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 {
|
||||||
|
|
|
@ -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",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>⁄<sub>${fraction[2]}</sub>`;
|
returnQty += ` <sup>${fraction[1]}</sup>⁄<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, " ");
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
61
tests/fixtures/fixture_users.py
vendored
61
tests/fixtures/fixture_users.py
vendored
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue