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}