diff --git a/cookbook/helper/permission_helper.py b/cookbook/helper/permission_helper.py index 1837f1a2..739650d7 100644 --- a/cookbook/helper/permission_helper.py +++ b/cookbook/helper/permission_helper.py @@ -299,6 +299,27 @@ class CustomIsShare(permissions.BasePermission): return False +class CustomRecipePermission(permissions.BasePermission): + """ + Custom permission class for recipe api endpoint + """ + message = _('You do not have the required permissions to view this page!') + + def has_permission(self, request, view): # user is either at least a guest or a share link is given and the request is safe + share = request.query_params.get('share', None) + return has_group_permission(request.user, ['guest']) or (share and request.method in SAFE_METHODS and 'pk' in view.kwargs) + + def has_object_permission(self, request, view, obj): + share = request.query_params.get('share', None) + if share: + return share_link_valid(obj, share) + else: + if obj.private: + return ((obj.created_by == request.user) or (request.user in obj.shared.all())) and obj.space == request.space + else: + return has_group_permission(request.user, ['guest']) and obj.space == request.space + + def above_space_limit(space): # TODO add file storage limit """ Test if the space has reached any limit (e.g. max recipes, users, ..) diff --git a/cookbook/migrations/0179_recipe_private_recipe_shared.py b/cookbook/migrations/0179_recipe_private_recipe_shared.py new file mode 100644 index 00000000..9d3914b1 --- /dev/null +++ b/cookbook/migrations/0179_recipe_private_recipe_shared.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.6 on 2022-07-13 10:53 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cookbook', '0178_remove_userpreference_search_style_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='recipe', + name='private', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='recipe', + name='shared', + field=models.ManyToManyField(blank=True, related_name='recipe_shared_with', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index a0457ef2..aa1e2800 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -738,6 +738,8 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel internal = models.BooleanField(default=False) nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE) show_ingredient_overview = models.BooleanField(default=True) + private = models.BooleanField(default=False) + shared = models.ManyToManyField(User, blank=True, related_name='recipe_shared_with') source_url = models.CharField(max_length=1024, default=None, blank=True, null=True) created_by = models.ForeignKey(User, on_delete=models.PROTECT) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 718964f1..e22e38aa 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -5,7 +5,7 @@ from gettext import gettext as _ from html import escape from smtplib import SMTPException -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group, User, AnonymousUser from django.core.mail import send_mail from django.db.models import Avg, Q, QuerySet, Sum from django.http import BadHeaderError @@ -124,7 +124,10 @@ class SpaceFilterSerializer(serializers.ListSerializer): # if query is sliced it came from api request not nested serializer return super().to_representation(data) if self.child.Meta.model == User: - data = data.filter(userspace__space=self.context['request'].user.get_active_space()).all() + if type(self.context['request'].user) == AnonymousUser: + data = [] + else: + data = data.filter(userspace__space=self.context['request'].user.get_active_space()).all() else: data = data.filter(**{'__'.join(data.model.get_space_key()): self.context['request'].space}) return super().to_representation(data) @@ -732,6 +735,7 @@ class RecipeSerializer(RecipeBaseSerializer): keywords = KeywordSerializer(many=True) rating = serializers.SerializerMethodField('get_recipe_rating') last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked') + shared = UserNameSerializer(many=True) class Meta: model = Recipe @@ -739,6 +743,7 @@ class RecipeSerializer(RecipeBaseSerializer): 'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time', 'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url', 'internal', 'show_ingredient_overview', 'nutrition', 'servings', 'file_path', 'servings_text', 'rating', 'last_cooked', + 'private', 'shared', ) read_only_fields = ['image', 'created_by', 'created_at'] diff --git a/cookbook/tests/api/test_api_recipe.py b/cookbook/tests/api/test_api_recipe.py index 2ff97669..e33dbdfe 100644 --- a/cookbook/tests/api/test_api_recipe.py +++ b/cookbook/tests/api/test_api_recipe.py @@ -1,6 +1,7 @@ import json import pytest +from django.contrib import auth from django.urls import reverse from django_scopes import scopes_disabled @@ -30,6 +31,7 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2): assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 1 assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 0 + # test for space filter with scopes_disabled(): recipe_1_s1.space = space_2 recipe_1_s1.save() @@ -37,8 +39,23 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2): assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 0 assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 1 + # test for private recipe filter + with scopes_disabled(): + recipe_1_s1.created_by = auth.get_user(u1_s1) + recipe_1_s1.private = True + recipe_1_s1.save() -def test_share_permission(recipe_1_s1, u1_s1, u1_s2, a_u): + assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 0 + assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 0 + + with scopes_disabled(): + recipe_1_s1.created_by = auth.get_user(u1_s2) + recipe_1_s1.save() + + assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 1 + + +def test_share_permission(recipe_1_s1, u1_s1, u1_s2, u2_s1, a_u): assert u1_s1.get(reverse(DETAIL_URL, args=[recipe_1_s1.pk])).status_code == 200 assert u1_s2.get(reverse(DETAIL_URL, args=[recipe_1_s1.pk])).status_code == 404 @@ -52,6 +69,15 @@ def test_share_permission(recipe_1_s1, u1_s1, u1_s2, a_u): assert u1_s1.get(reverse(DETAIL_URL, args=[recipe_1_s1.pk]) + f'?share={share.uuid}').status_code == 200 assert u1_s2.get(reverse(DETAIL_URL, args=[recipe_1_s1.pk]) + f'?share={share.uuid}').status_code == 404 # TODO fix in https://github.com/TandoorRecipes/recipes/issues/1238 + recipe_1_s1.created_by = auth.get_user(u1_s1) + recipe_1_s1.private = True + recipe_1_s1.save() + + assert a_u.get(reverse(DETAIL_URL, args=[recipe_1_s1.pk]) + f'?share={share.uuid}').status_code == 200 + assert u1_s1.get(reverse(DETAIL_URL, args=[recipe_1_s1.pk]) + f'?share={share.uuid}').status_code == 200 + assert u2_s1.get(reverse(DETAIL_URL, args=[recipe_1_s1.pk]) + f'?share={share.uuid}').status_code == 200 + assert u2_s1.get(reverse(DETAIL_URL, args=[recipe_1_s1.pk])).status_code == 403 + @pytest.mark.parametrize("arg", [ ['a_u', 403], @@ -80,6 +106,22 @@ def test_update(arg, request, recipe_1_s1): validate_recipe(j, json.loads(r.content)) +def test_update_private_recipe(u1_s1, u2_s1, recipe_1_s1): + r = u1_s1.patch(reverse(DETAIL_URL, args={recipe_1_s1.id}), {'name': 'test1'}, content_type='application/json') + assert r.status_code == 200 + + with scopes_disabled(): + recipe_1_s1.private = True + recipe_1_s1.created_by = auth.get_user(u1_s1) + recipe_1_s1.save() + + r = u1_s1.patch(reverse(DETAIL_URL, args={recipe_1_s1.id}), {'name': 'test2'}, content_type='application/json') + assert r.status_code == 200 + + r = u2_s1.patch(reverse(DETAIL_URL, args={recipe_1_s1.id}), {'name': 'test3'}, content_type='application/json') + assert r.status_code == 403 + + @pytest.mark.parametrize("arg", [ ['a_u', 403], ['g1_s1', 201], @@ -107,22 +149,22 @@ def test_add(arg, request, u1_s2): x += 1 -def test_delete(u1_s1, u1_s2, recipe_1_s1): +def test_delete(u1_s1, u1_s2, u2_s1, recipe_1_s1, recipe_2_s1): with scopes_disabled(): - r = u1_s2.delete( - reverse( - DETAIL_URL, - args={recipe_1_s1.id} - ) - ) + r = u1_s2.delete(reverse(DETAIL_URL, args={recipe_1_s1.id})) assert r.status_code == 404 - r = u1_s1.delete( - reverse( - DETAIL_URL, - args={recipe_1_s1.id} - ) - ) + r = u1_s1.delete(reverse(DETAIL_URL, args={recipe_1_s1.id})) assert r.status_code == 204 assert not Recipe.objects.filter(pk=recipe_1_s1.id).exists() + + recipe_2_s1.created_by = auth.get_user(u1_s1) + recipe_2_s1.private = True + recipe_2_s1.save() + + r = u2_s1.delete(reverse(DETAIL_URL, args={recipe_2_s1.id})) + assert r.status_code == 403 + + r = u1_s1.delete(reverse(DETAIL_URL, args={recipe_2_s1.id})) + assert r.status_code == 204 diff --git a/cookbook/views/api.py b/cookbook/views/api.py index c8b0cce6..14be5e0e 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -53,7 +53,7 @@ from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, CustomIsOwner, CustomIsOwnerReadOnly, CustomIsShare, CustomIsShared, CustomIsSpaceOwner, CustomIsUser, group_required, - is_space_owner, switch_user_active_space, above_space_limit) + is_space_owner, switch_user_active_space, above_space_limit, CustomRecipePermission) from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch from cookbook.helper.recipe_url_import import get_from_youtube_scraper, get_images_from_soup from cookbook.helper.scrapers.scrapers import text_scraper @@ -715,7 +715,7 @@ class RecipeViewSet(viewsets.ModelViewSet): queryset = Recipe.objects serializer_class = RecipeSerializer # TODO split read and write permission for meal plan guest - permission_classes = [CustomIsShare | CustomIsGuest] + permission_classes = [CustomRecipePermission] pagination_class = RecipePagination query_params = [ @@ -782,13 +782,14 @@ class RecipeViewSet(viewsets.ModelViewSet): def get_queryset(self): share = self.request.query_params.get('share', None) - if self.detail: - if not share: + if self.detail: # if detail request and not list, private condition is verified by permission class + if not share: # filter for space only if not shared self.queryset = self.queryset.filter(space=self.request.space) return super().get_queryset() - if not (share and self.detail): - self.queryset = self.queryset.filter(space=self.request.space) + self.queryset = self.queryset.filter(space=self.request.space).filter( + Q(private=False) | (Q(private=True) & (Q(created_by=self.request.user) | Q(shared=self.request.user))) + ) params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x in list(self.request.GET)} @@ -803,8 +804,6 @@ class RecipeViewSet(viewsets.ModelViewSet): }) return super().list(request, *args, **kwargs) - # TODO write extensive tests for permissions - def get_serializer_class(self): if self.action == 'list': return RecipeOverviewSerializer diff --git a/vue/src/apps/RecipeEditView/RecipeEditView.vue b/vue/src/apps/RecipeEditView/RecipeEditView.vue index 2aec5c6d..ef7affe6 100644 --- a/vue/src/apps/RecipeEditView/RecipeEditView.vue +++ b/vue/src/apps/RecipeEditView/RecipeEditView.vue @@ -188,10 +188,31 @@
- + + +
+ + + {{ $t('Private_Recipe_Help') }} + + +
+ + + + @@ -723,6 +744,7 @@ import GenericModalForm from "@/components/Modals/GenericModalForm" import mavonEditor from 'mavon-editor' import 'mavon-editor/dist/css/index.css' import _debounce from "lodash/debounce"; +import GenericMultiselect from "@/components/GenericMultiselect"; // use Vue.use(mavonEditor) @@ -731,7 +753,7 @@ Vue.use(BootstrapVue) export default { name: "RecipeEditView", mixins: [ResolveUrlMixin, ApiMixin], - components: {Multiselect, LoadingSpinner, draggable, GenericModalForm}, + components: {Multiselect, LoadingSpinner, draggable, GenericModalForm, GenericMultiselect}, data() { return { recipe_id: window.RECIPE_ID, diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index 5f9535fb..37df9e91 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -68,6 +68,10 @@ "Enable_Amount": "Enable Amount", "Disable_Amount": "Disable Amount", "Ingredient Editor": "Ingredient Editor", + + "Private_Recipe": "Private Recipe", + "Private_Recipe_Help": "Recipe is only shown to you and people its shared with.", + "Add_Step": "Add Step", "Keywords": "Keywords", "Books": "Books", diff --git a/vue/src/utils/api.js b/vue/src/utils/api.js index 8d35049d..292aa931 100644 --- a/vue/src/utils/api.js +++ b/vue/src/utils/api.js @@ -8,7 +8,7 @@ axios.defaults.xsrfHeaderName = "X-CSRFTOKEN" export function apiLoadRecipe(recipe_id) { let url = resolveDjangoUrl('api:recipe-detail', recipe_id) - if (window.SHARE_UID !== undefined) { + if (window.SHARE_UID !== undefined && window.SHARE_UID !== 'None') { url += '?share=' + window.SHARE_UID } diff --git a/vue/src/utils/openapi/api.ts b/vue/src/utils/openapi/api.ts index f3f92a33..aa979840 100644 --- a/vue/src/utils/openapi/api.ts +++ b/vue/src/utils/openapi/api.ts @@ -2023,6 +2023,12 @@ export interface RecipeBookFilter { * @interface RecipeFile */ export interface RecipeFile { + /** + * + * @type {number} + * @memberof RecipeFile + */ + id?: number; /** * * @type {string} @@ -2031,16 +2037,16 @@ export interface RecipeFile { name: string; /** * - * @type {any} + * @type {string} * @memberof RecipeFile */ - file?: any; + file_download?: string; /** * - * @type {number} + * @type {string} * @memberof RecipeFile */ - id?: number; + preview?: string; } /** * @@ -3540,18 +3546,6 @@ export interface UserPreference { * @memberof UserPreference */ use_kj?: boolean; - /** - * - * @type {string} - * @memberof UserPreference - */ - search_style?: UserPreferenceSearchStyleEnum; - /** - * - * @type {boolean} - * @memberof UserPreference - */ - show_recent?: boolean; /** * * @type {Array} @@ -3690,15 +3684,6 @@ export enum UserPreferenceDefaultPageEnum { Plan = 'PLAN', Books = 'BOOKS' } -/** - * @export - * @enum {string} - */ -export enum UserPreferenceSearchStyleEnum { - Small = 'SMALL', - Large = 'LARGE', - New = 'NEW' -} /** * @@ -4713,7 +4698,40 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) }; }, /** - * function to retrieve a recipe from a given url or source string :param request: standard request with additional post parameters - url: url to use for importing recipe - data: if no url is given recipe is imported from provided source data - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes :return: JsonResponse containing the parsed json, original html,json and images + * function to handle files passed by application importer + * @param {any} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createimportFiles: async (body?: any, options: any = {}): Promise => { + const localVarPath = `/api/import/`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * function to retrieve a recipe from a given url or source string :param request: standard request with additional post parameters - url: url to use for importing recipe - data: if no url is given recipe is imported from provided source data - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes :return: JsonResponse containing the parsed json and images * @param {any} [body] * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -11014,7 +11032,17 @@ export const ApiApiFp = function(configuration?: Configuration) { return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** - * function to retrieve a recipe from a given url or source string :param request: standard request with additional post parameters - url: url to use for importing recipe - data: if no url is given recipe is imported from provided source data - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes :return: JsonResponse containing the parsed json, original html,json and images + * function to handle files passed by application importer + * @param {any} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createimportFiles(body?: any, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createimportFiles(body, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * function to retrieve a recipe from a given url or source string :param request: standard request with additional post parameters - url: url to use for importing recipe - data: if no url is given recipe is imported from provided source data - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes :return: JsonResponse containing the parsed json and images * @param {any} [body] * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -13042,7 +13070,16 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: return localVarFp.createViewLog(viewLog, options).then((request) => request(axios, basePath)); }, /** - * function to retrieve a recipe from a given url or source string :param request: standard request with additional post parameters - url: url to use for importing recipe - data: if no url is given recipe is imported from provided source data - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes :return: JsonResponse containing the parsed json, original html,json and images + * function to handle files passed by application importer + * @param {any} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createimportFiles(body?: any, options?: any): AxiosPromise { + return localVarFp.createimportFiles(body, options).then((request) => request(axios, basePath)); + }, + /** + * function to retrieve a recipe from a given url or source string :param request: standard request with additional post parameters - url: url to use for importing recipe - data: if no url is given recipe is imported from provided source data - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes :return: JsonResponse containing the parsed json and images * @param {any} [body] * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -14958,7 +14995,18 @@ export class ApiApi extends BaseAPI { } /** - * function to retrieve a recipe from a given url or source string :param request: standard request with additional post parameters - url: url to use for importing recipe - data: if no url is given recipe is imported from provided source data - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes :return: JsonResponse containing the parsed json, original html,json and images + * function to handle files passed by application importer + * @param {any} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public createimportFiles(body?: any, options?: any) { + return ApiApiFp(this.configuration).createimportFiles(body, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * function to retrieve a recipe from a given url or source string :param request: standard request with additional post parameters - url: url to use for importing recipe - data: if no url is given recipe is imported from provided source data - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes :return: JsonResponse containing the parsed json and images * @param {any} [body] * @param {*} [options] Override http request option. * @throws {RequiredError}