mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2026-01-01 12:18:45 -05:00
added ability to mark recipes as private
This commit is contained in:
@@ -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, ..)
|
||||
|
||||
25
cookbook/migrations/0179_recipe_private_recipe_shared.py
Normal file
25
cookbook/migrations/0179_recipe_private_recipe_shared.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user