added ability to mark recipes as private

This commit is contained in:
vabene1111
2022-07-13 15:46:39 +02:00
parent 51076d4ced
commit e91790f5ac
10 changed files with 224 additions and 56 deletions

View File

@@ -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, ..)

View File

@@ -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),
),
]

View File

@@ -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)

View File

@@ -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']

View File

@@ -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

View File

@@ -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