From 5f1e01e2bea949042489020cfa9a3003f731445d Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Sun, 29 Nov 2020 19:53:42 +0000 Subject: [PATCH 01/31] minor: LDPPermissions overhaul (without caching) --- djangoldp/filters.py | 20 +- djangoldp/models.py | 49 +++- djangoldp/permissions.py | 267 +++++++++--------- djangoldp/serializers.py | 21 +- djangoldp/tests/permissions.py | 35 --- .../tests/tests_anonymous_permissions.py | 6 +- djangoldp/tests/tests_get.py | 3 - djangoldp/tests/tests_guardian.py | 122 +++++--- djangoldp/tests/tests_user_permissions.py | 71 ++++- djangoldp/views.py | 14 + 10 files changed, 353 insertions(+), 255 deletions(-) delete mode 100644 djangoldp/tests/permissions.py diff --git a/djangoldp/filters.py b/djangoldp/filters.py index 4fc943c4..0eb46c9b 100644 --- a/djangoldp/filters.py +++ b/djangoldp/filters.py @@ -1,5 +1,6 @@ from django.conf import settings from guardian.utils import get_anonymous_user +from guardian.shortcuts import get_group_obj_perms_model from rest_framework.filters import BaseFilterBackend from rest_framework_guardian.filters import ObjectPermissionsFilter @@ -10,15 +11,28 @@ class LDPPermissionsFilterBackend(ObjectPermissionsFilter): Django-Guardian's get_objects_for_user """ def filter_queryset(self, request, queryset, view): - from djangoldp.permissions import LDPPermissions + from djangoldp.models import Model + from djangoldp.permissions import LDPPermissions, OwnerAuthAnonPermissions # compares the requirement for GET, with what the user has on the MODEL - if LDPPermissions.has_model_view_permission(request, view.model): + ldp_permissions = LDPPermissions() + if ldp_permissions.has_model_permission(request, view): return queryset if not request.user.is_anonymous or ( getattr(settings, 'ANONYMOUS_USER_NAME', True) is not None and request.user != get_anonymous_user()): - return super().filter_queryset(request, queryset, view) + # those objects I have by grace of group or object + object_perms = super().filter_queryset(request, queryset, view) + + # those objects I have by grace of being owner + if Model.get_meta(view.model, 'owner_field', None) is not None: + perms_class = OwnerAuthAnonPermissions() + owner_perms = perms_class.get_permission_settings(view.model)[2] + if 'view' in owner_perms: + owned_objects = [q.pk for q in queryset if Model.is_owner(view.model, request.user, q)] + return object_perms | queryset.filter(pk__in=owned_objects) + return object_perms + # user is anonymous without anonymous permissions return view.model.objects.none() diff --git a/djangoldp/models.py b/djangoldp/models.py index 5a9bc2d4..451e49b2 100644 --- a/djangoldp/models.py +++ b/djangoldp/models.py @@ -265,7 +265,7 @@ class Model(models.Model): return None @classonlymethod - def get_permission_classes(cls, related_model, default_permissions_classes) -> LDPPermissions: + def get_permission_classes(cls, related_model, default_permissions_classes): '''returns the permission_classes set in the models Meta class''' return cls.get_meta(related_model, 'permission_classes', default_permissions_classes) @@ -278,12 +278,47 @@ class Model(models.Model): meta = default return getattr(model_class._meta, meta_name, meta) - @staticmethod - def get_permissions(obj_or_model, context, filter): - permissions = filter - for permission_class in Model.get_permission_classes(obj_or_model, [LDPPermissions]): - permissions = permission_class().filter_user_perms(context, obj_or_model, permissions) - return [{'mode': {'@type': name.split('_')[0]}} for name in permissions] + @classmethod + def get_model_class(cls): + return cls + + @classonlymethod + def get_model_permissions(cls, model_class, request, view, obj=None): + '''outputs the permissions given by all permissions_classes on the model_class on the model-level''' + perms = set() + for permission_class in Model.get_permission_classes(model_class, [LDPPermissions]): + if hasattr(permission_class, 'get_model_permissions'): + perms = perms.union(permission_class().get_model_permissions(request, view, obj)) + return perms + + @classonlymethod + def get_object_permissions(cls, model_class, request, view, obj): + '''outputs the permissions given by all permissions_classes on the model_class on the object-level''' + perms = set() + for permission_class in Model.get_permission_classes(model_class, [LDPPermissions]): + if hasattr(permission_class, 'get_object_permissions'): + perms = perms.union(permission_class().get_object_permissions(request, view, obj)) + return perms + + @classonlymethod + def get_permissions(cls, model_class, request, view, obj=None): + '''outputs the permissions given by all permissions_classes on the model_class on both the model and the object level''' + perms = Model.get_model_permissions(model_class, request, view, obj) + if obj is not None: + perms = perms.union(Model.get_object_permissions(model_class, request, view, obj)) + return perms + + @classmethod + def is_owner(cls, model_class, user, obj): + '''returns True if I given user is the owner of given object instance, otherwise False''' + owner_field = Model.get_meta(model_class, 'owner_field') + + if owner_field is None: + return False + + return (getattr(obj, owner_field) == user + or (hasattr(user, 'urlid') and getattr(obj, owner_field) == user.urlid) + or getattr(obj, owner_field) == user.id) @classmethod def is_external(cls, value): diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 6be469ad..2b7cebff 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -1,21 +1,77 @@ import time from django.conf import settings from django.contrib.auth.models import _user_get_all_permissions -from django.core.exceptions import PermissionDenied -from django.db.models.base import ModelBase from rest_framework.permissions import DjangoObjectPermissions from djangoldp.filters import LDPPermissionsFilterBackend -class LDPPermissions(DjangoObjectPermissions): - # *DEFAULT* permissions for anon, auth and owner statuses - anonymous_perms = ['view'] - authenticated_perms = ['inherit'] - owner_perms = ['inherit'] +class LDPBasePermission(DjangoObjectPermissions): + """ + A base class from which all permission classes should inherit. + Extends the DRF permissions class to include the concept of model-permissions, separate from the view, and to + change to a system of outputting permissions sets for the serialization of WebACLs + """ # filter backends associated with the permissions class. This will be used to filter queryset in the (auto-generated) # view for a model, and in the serializing nested fields - filter_backends = [LDPPermissionsFilterBackend] + filter_backends = [] + # perms_map defines the permissions required for different methods + perms_map = { + 'GET': ['%(app_label)s.view_%(model_name)s'], + 'OPTIONS': [], + 'HEAD': ['%(app_label)s.view_%(model_name)s'], + 'POST': ['%(app_label)s.add_%(model_name)s'], + 'PUT': ['%(app_label)s.change_%(model_name)s'], + 'PATCH': ['%(app_label)s.change_%(model_name)s'], + 'DELETE': ['%(app_label)s.delete_%(model_name)s'], + } + + def get_model_permissions(self, request, view, obj=None): + """ + outputs a set of permissions of a given model (container). Used in the generation of WebACLs in LDPSerializer + rarely need to override this function + """ + return set() + + def get_object_permissions(self, request, view, obj): + """ + outputs the permissions of a given object instance. Used in the generation of WebACLs in LDPSerializer + rarely need to override this function + """ + return set() + + def get_user_permissions(self, request, view, obj=None): + '''returns a set of all model permissions and object permissions for given parameters''' + perms = self.get_model_permissions(request, view, obj) + if obj is not None: + return perms.union(self.get_object_permissions(request, view, obj)) + return perms + + def has_permission(self, request, view): + """concerned with the permissions to access the _view_""" + return True + + def has_model_permission(self, request, view): + """ + concerned with the permissions to access the _model_ + in most situations you won't need to override this. It is primarily called by has_object_permission + """ + required_perms = self.get_required_permissions(request.method, view.model) + return self.compare_permissions(required_perms, self.get_model_permissions(request, view)) + + def has_object_permission(self, request, view, obj): + """concerned with the permissions to access the _object_""" + required_perms = self.get_required_permissions(request.method, view.model) + return self.compare_permissions(required_perms, self.get_user_permissions(request, view, obj)) + def compare_permissions(self, required_perms, user_perms): + '''returns True if all user_perms are in required_perms''' + for perm in required_perms: + if not perm.split('.')[-1].split('_')[0] in user_perms: + return False + return True + + +class CachedLDPBasePermission(LDPBasePermission): perms_cache = { 'time': time.time() } @@ -32,42 +88,80 @@ class LDPPermissions(DjangoObjectPermissions): if (time.time() - cls.perms_cache['time']) > 5: cls.invalidate_cache() - @classmethod - def is_owner(cls, user, model, obj): - return obj and hasattr(model._meta, 'owner_field') and ( - getattr(obj, getattr(model._meta, 'owner_field')) == user - or (hasattr(user, 'urlid') and getattr(obj, getattr(model._meta, 'owner_field')) == user.urlid) - or getattr(obj, getattr(model._meta, 'owner_field')) == user.id) - def _get_cache_key(self, model_name, user, obj): user_key = 'None' if user is None else user.id obj_key = 'None' if obj is None else obj.id return 'User{}{}{}'.format(user_key, model_name, obj_key) - @classmethod - def get_model_level_perms(cls, model, user, obj=None): - '''Auxiliary function returns the model-level anon-auth-owner permissions for a given, model, user and object''' - anonymous_perms = getattr(model._meta, 'anonymous_perms', cls.anonymous_perms) - authenticated_perms = getattr(model._meta, 'authenticated_perms', cls.authenticated_perms) - owner_perms = getattr(model._meta, 'owner_perms', cls.owner_perms) - - # 'inherit' permissions means inherit the permissions from the next level 'down' - if 'inherit' in authenticated_perms: - authenticated_perms = authenticated_perms + list(set(anonymous_perms) - set(authenticated_perms)) - if 'inherit' in owner_perms: - owner_perms = owner_perms + list(set(authenticated_perms) - set(owner_perms)) - - # apply user permissions and return - perms = set() - if user.is_anonymous: + +class OwnerAuthAnonPermissions(LDPBasePermission): + # *DEFAULT* model-level permissions for anon, auth and owner statuses + anonymous_perms = ['view'] + authenticated_perms = ['inherit'] + owner_perms = ['inherit'] + + def _get_permissions_setting(self, model, setting, parent_perms=None): + '''Auxiliary function returns the configured permissions given to parameterised setting, or default''' + from djangoldp.models import Model + + # gets the model-configured setting or default if it exists + return_perms = Model.get_meta(model, setting, getattr(self, setting)) + + if parent_perms is not None and 'inherit' in return_perms: + return_perms = return_perms + list(set(parent_perms) - set(return_perms)) + + return return_perms + + def get_permission_settings(self, model): + '''returns a tuple of (Auth, Anon, Owner) settings for a given model''' + anonymous_perms = self._get_permissions_setting(model, 'anonymous_perms') + authenticated_perms = self._get_permissions_setting(model, 'authenticated_perms', anonymous_perms) + owner_perms = self._get_permissions_setting(model, 'owner_perms', authenticated_perms) + + return anonymous_perms, authenticated_perms, owner_perms + + def get_model_permissions(self, request, view, obj=None): + '''analyses the Model's set anonymous, authenticated and owner_permissions and returns these''' + from djangoldp.models import Model + + model = view.model + anonymous_perms, authenticated_perms, owner_perms = self.get_permission_settings(model) + + perms = super().get_model_permissions(request, view, obj) + if request.user.is_anonymous: perms = perms.union(set(anonymous_perms)) else: - if cls.is_owner(user, model, obj): + if obj is not None and Model.is_owner(view.model, request.user, obj): perms = perms.union(set(owner_perms)) else: perms = perms.union(set(authenticated_perms)) return perms + +class LDPObjectLevelPermissions(LDPBasePermission): + def get_object_permissions(self, request, view, obj): + '''overridden to append permissions from all backends given to the user (e.g. Groups and object-level perms)''' + from djangoldp.models import Model + + model_name = Model.get_meta(view.model, 'model_name') + + perms = super().get_object_permissions(request, view, obj) + + if obj is not None and not request.user.is_anonymous: + forbidden_string = "_" + model_name + return perms.union(set([p.replace(forbidden_string, '') for p in _user_get_all_permissions(request.user, obj)])) + + return perms + + +class LDPPermissions(LDPObjectLevelPermissions, OwnerAuthAnonPermissions, CachedLDPBasePermission): + filter_backends = [LDPPermissionsFilterBackend] + + +''' +class OldLDPPermissions(DjangoObjectPermissions): + filter_backends = [LDPPermissionsFilterBackend] + def user_permissions(self, user, obj_or_model, obj=None): """ Filter user permissions for a model class @@ -88,114 +182,7 @@ class LDPPermissions(DjangoObjectPermissions): # return permissions - using set to avoid duplicates perms = self.get_model_level_perms(model, user, obj) - if obj is not None and not user.is_anonymous: - # get permissions from all backends and then remove model name from the permissions - forbidden_string = "_" + model_name - perms = perms.union(set([p.replace(forbidden_string, '') for p in _user_get_all_permissions(user, obj)])) - self.perms_cache[perms_cache_key] = list(perms) return self.perms_cache[perms_cache_key] - - def cache_key(self, model, obj, user): - model_name = model._meta.model_name - user_key = 'None' if user is None else user.id - obj_key = 'None' if obj is None else obj.id - perms_cache_key = 'User{}{}{}'.format(user_key, model_name, obj_key) - return perms_cache_key - - def filter_user_perms(self, context, obj_or_model, permissions): - # Only used on Model.get_permissions to translate permissions to LDP - return [perm for perm in permissions if perm in self.user_permissions(context['request'].user, obj_or_model)] - - # perms_map defines the permissions required for different methods - perms_map = { - 'GET': ['%(app_label)s.view_%(model_name)s'], - 'OPTIONS': [], - 'HEAD': ['%(app_label)s.view_%(model_name)s'], - 'POST': ['%(app_label)s.add_%(model_name)s'], - 'PUT': ['%(app_label)s.change_%(model_name)s'], - 'PATCH': ['%(app_label)s.change_%(model_name)s'], - 'DELETE': ['%(app_label)s.delete_%(model_name)s'], - } - - @classmethod - def get_permissions(cls, method, obj): - """ - Translate perms_map to request - """ - kwargs = { - 'app_label': obj._meta.app_label, - 'model_name': obj._meta.model_name - } - - # Only allows methods that are on perms_map - if method not in cls.perms_map: - raise PermissionDenied - - return [perm % kwargs for perm in cls.perms_map[method]] - - def has_permission(self, request, view): - """ - Access to containers - """ - from djangoldp.models import Model - - if self.is_a_container(request._request.path): - try: - obj = Model.resolve_parent(request.path) - model = view.parent_model - except: - obj = None - model = view.model - else: - obj = Model.resolve_id(request._request.path) - model = view.model - - # get permissions required - perms = LDPPermissions.get_permissions(request.method, model) - user_perms = self.user_permissions(request.user, model, obj) - - # compare them with the permissions I have - for perm in perms: - if not perm.split('.')[-1].split('_')[0] in user_perms: - return False - - return True - - def is_a_container(self, path): - from djangoldp.models import Model - container, id = Model.resolve(path) - return id is None - - def has_object_permission(self, request, view, obj): - """ - Access to objects - User have permission on request: Continue - User does not have permission: 403 - """ - # get permissions required - perms = LDPPermissions.get_permissions(request.method, obj) - model = obj - user_perms = self.user_permissions(request.user, model, obj) - - return LDPPermissions.compare_permissions(perms, user_perms) - - @classmethod - def has_model_view_permission(cls, request, model): - ''' - shortcut to compare the requested user's permissions on the model-level - :return: True or False - ''' - # compare required permissions with those I have (on the model) - perms = LDPPermissions.get_permissions('GET', model) - user_perms = LDPPermissions.get_model_level_perms(model, request.user) - return cls.compare_permissions(perms, user_perms) - - @classmethod - def compare_permissions(self, perms, user_perms): - # compare them with the permissions I have - for perm in perms: - if not perm.split('.')[-1].split('_')[0] in user_perms: - return False - return True +''' diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index 567c41bd..acc3fcf7 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -110,7 +110,7 @@ class LDListMixin: if self.with_cache and self.to_representation_cache.has(cache_key, cache_vary): return self.to_representation_cache.get(cache_key, cache_vary) - container_permissions = Model.get_permissions(child_model, self.context, ['view', 'add']) + container_permissions = list(Model.get_model_permissions(child_model, self.context['request'], self.context['view'])) else: # this is a container. Parent model is the containing object, child the model contained @@ -131,9 +131,9 @@ class LDListMixin: value = child_model.get_queryset(self.context['request'], self.context['view'], queryset=value, model=child_model) - container_permissions = Model.get_permissions(child_model, self.context, ['add']) - container_permissions.extend( - Model.get_permissions(parent_model, self.context, ['view'])) + container_permissions = Model.get_model_permissions(child_model, self.context['request'], self.context['view']) + container_permissions = list(container_permissions.union( + Model.get_model_permissions(parent_model, self.context['request'], self.context['view']))) self.to_representation_cache.set(self.id, cache_vary, {'@id': self.id, '@type': 'ldp:Container', @@ -353,8 +353,11 @@ class LDPSerializer(HyperlinkedModelSerializer): data['@type'] = rdf_type if rdf_context is not None: data['@context'] = rdf_context - data['permissions'] = Model.get_permissions(obj, self.context, - ['view', 'change', 'control', 'delete']) + if hasattr(obj, 'get_model_class'): + model_class = obj.get_model_class() + else: + model_class = type(obj) + data['permissions'] = list(Model.get_permissions(model_class, self.context['request'], self.context['view'], obj)) return data @@ -389,9 +392,9 @@ class LDPSerializer(HyperlinkedModelSerializer): 'ldp:contains': [serializer.to_representation(item) if item is not None else None for item in data], - 'permissions': Model.get_permissions(self.parent.Meta.model, - self.context, - ['view', 'add']) + 'permissions': list(Model.get_permissions(self.parent.Meta.model, + self.parent.context['request'], + self.parent.context['view'])) } else: return serializer.to_representation(instance) diff --git a/djangoldp/tests/permissions.py b/djangoldp/tests/permissions.py deleted file mode 100644 index f8ec6d3c..00000000 --- a/djangoldp/tests/permissions.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.db.models import QuerySet -from django.db.models.base import ModelBase - -from djangoldp.permissions import LDPPermissions - - -class HalfRandomPermissions(LDPPermissions): - - def prefilter_query_set(self, query_set: QuerySet, request, view, model) -> QuerySet: - if request.user.is_anonymous: - return query_set.filter(pk__in=[2, 4, 6, 8]) - else: - return super().prefilter_query_set(query_set, request, view, model) - - def user_permissions(self, user, obj_or_model, obj=None): - if isinstance(obj_or_model, ModelBase): - model = obj_or_model - else: - obj = obj_or_model - model = obj_or_model.__class__ - - # perms_cache_key = self.cache_key(model, obj, user) - # if self.with_cache and perms_cache_key in self.perms_cache: - # return self.perms_cache[perms_cache_key] - - # start with the permissions set on the object and model - perms = set(super().user_permissions(user, obj_or_model, obj)) - - if obj is not None and not isinstance(obj, ModelBase) and user.is_anonymous: - if obj.pk % 2 == 0: - return ['add', 'view'] - else: - return [] - else: - return ['view'] diff --git a/djangoldp/tests/tests_anonymous_permissions.py b/djangoldp/tests/tests_anonymous_permissions.py index adfb255e..2bd48b6a 100644 --- a/djangoldp/tests/tests_anonymous_permissions.py +++ b/djangoldp/tests/tests_anonymous_permissions.py @@ -3,9 +3,7 @@ import json from django.test import TestCase from rest_framework.test import APIClient -from djangoldp.permissions import LDPPermissions from djangoldp.tests.models import JobOffer -from djangoldp.views import LDPViewSet class TestAnonymousUserPermissions(TestCase): @@ -30,9 +28,9 @@ class TestAnonymousUserPermissions(TestCase): body = {'title':"job_updated"} response = self.client.put('/job-offers/{}/'.format(self.job.pk), data=json.dumps(body), content_type='application/ld+json') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 404) def test_patch_request_for_anonymousUser(self): response = self.client.patch('/job-offers/' + str(self.job.pk) + "/", content_type='application/ld+json') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 404) diff --git a/djangoldp/tests/tests_get.py b/djangoldp/tests/tests_get.py index 7ae5c76f..cf038f47 100644 --- a/djangoldp/tests/tests_get.py +++ b/djangoldp/tests/tests_get.py @@ -1,5 +1,4 @@ from djangoldp.serializers import LDListMixin, LDPSerializer -from django.contrib.auth import get_user_model from datetime import datetime from rest_framework.test import APIRequestFactory, APIClient, APITestCase @@ -41,9 +40,7 @@ class TestGET(APITestCase): Post.objects.create(content="federated", urlid="https://external.com/posts/1/") response = self.client.get('/posts/', content_type='application/ld+json') self.assertEqual(response.status_code, 200) - self.assertIn('permissions', response.data) self.assertEquals(1, len(response.data['ldp:contains'])) - self.assertEquals(2, len(response.data['permissions'])) # read and add Invoice.objects.create(title="content") response = self.client.get('/invoices/', content_type='application/ld+json') diff --git a/djangoldp/tests/tests_guardian.py b/djangoldp/tests/tests_guardian.py index 79a770fd..bb2dbe1b 100644 --- a/djangoldp/tests/tests_guardian.py +++ b/djangoldp/tests/tests_guardian.py @@ -1,6 +1,7 @@ import json import uuid from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from djangoldp.serializers import LDListMixin, LDPSerializer from rest_framework.test import APIClient, APITestCase from guardian.shortcuts import assign_perm @@ -20,38 +21,91 @@ class TestsGuardian(APITestCase): def setUpLoggedInUser(self): self.user = get_user_model().objects.create_user(username='john', email='jlennon@beatles.com', password='glass onion') + self.group = Group.objects.create(name='Test') + self.user.groups.add(self.group) + self.user.save() self.client.force_authenticate(user=self.user) LDPPermissions.invalidate_cache() LDListMixin.to_representation_cache.reset() LDPSerializer.to_representation_cache.reset() - def _get_dummy_with_perms(self, perms=None, parent=None): + def _get_dummy_with_perms(self, perms=None, parent=None, group=False): if perms is None: perms = [] dummy = PermissionlessDummy.objects.create(some='test', slug=uuid.uuid4(), parent=parent) model_name = PermissionlessDummy._meta.model_name for perm in perms: - assign_perm(perm + '_' + model_name, self.user, dummy) + perm = perm + '_' + model_name + if group: + assign_perm(perm, self.group, dummy) + else: + assign_perm(perm, self.user, dummy) return dummy # optional setup for testing PermissionlessDummy model with parameterised perms - def setUpGuardianDummyWithPerms(self, perms=None, parent=None): - self.dummy = self._get_dummy_with_perms(perms, parent) + def setUpGuardianDummyWithPerms(self, perms=None, parent=None, group=False): + self.dummy = self._get_dummy_with_perms(perms, parent, group) # test that dummy with no permissions set returns no results def test_get_dummy_no_permissions(self): self.setUpLoggedInUser() self.setUpGuardianDummyWithPerms() response = self.client.get('/permissionless-dummys/{}/'.format(self.dummy.slug)) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 404) # test with anonymous user def test_get_dummy_anonymous_user(self): self.setUpGuardianDummyWithPerms() response = self.client.get('/permissionless-dummys/') - self.assertEqual(response.status_code, 403) + # I have no object permissions - I should receive a 200 with an empty list + # TODO: masking this view altogether, but allowing exceptions on model/group-levels + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 0) + + def test_list_dummy_exception(self): + self.setUpLoggedInUser() + # I have permission on a permissionless dummy, but not in general + dummy_a = self._get_dummy_with_perms() + dummy_b = self._get_dummy_with_perms(['view']) + response = self.client.get('/permissionless-dummys/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 1) + containees = [d['@id'] for d in response.data['ldp:contains']] + self.assertNotIn(dummy_a.urlid, containees) + self.assertIn(dummy_b.urlid, containees) + + def test_list_dummy_group_exception(self): + self.setUpLoggedInUser() + dummy_a = self._get_dummy_with_perms() + dummy_b = self._get_dummy_with_perms(['view'], group=True) + response = self.client.get('/permissionless-dummys/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 1) + containees = [d['@id'] for d in response.data['ldp:contains']] + self.assertNotIn(dummy_a.urlid, containees) + self.assertIn(dummy_b.urlid, containees) + + def test_list_dummy_exception_nested_view(self): + self.setUpLoggedInUser() + parent = LDPDummy.objects.create(some="test") + # two dummies, one I have permission to view and one I don't + dummy_a = self._get_dummy_with_perms(parent=parent) + dummy_b = self._get_dummy_with_perms(['view'], parent) + response = self.client.get('/ldpdummys/{}/anons/'.format(parent.pk)) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 1) + + def test_list_dummy_exception_nested_serializer(self): + self.setUpLoggedInUser() + parent = LDPDummy.objects.create(some="test") + # two dummies, one I have permission to view and one I don't + dummy_a = self._get_dummy_with_perms(parent=parent) + dummy_b = self._get_dummy_with_perms(['view'], parent) + response = self.client.get('/ldpdummys/{}/'.format(parent.pk)) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['anons']['ldp:contains']), 1) def test_get_dummy_permission_granted(self): self.setUpLoggedInUser() @@ -59,12 +113,18 @@ class TestsGuardian(APITestCase): response = self.client.get('/permissionless-dummys/{}/'.format(self.dummy.slug)) self.assertEqual(response.status_code, 200) + def test_get_dummy_group_permission_granted(self): + self.setUpLoggedInUser() + self.setUpGuardianDummyWithPerms(['view'], group=True) + response = self.client.get('/permissionless-dummys/{}/'.format(self.dummy.slug)) + self.assertEqual(response.status_code, 200) + def test_get_dummy_permission_rejected(self): self.setUpLoggedInUser() self.setUpGuardianDummyWithPerms(['view']) dummy_without = PermissionlessDummy.objects.create(some='test2', slug='test2') response = self.client.get('/permissionless-dummys/{}/'.format(dummy_without.slug)) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 404) def test_patch_dummy_permission_granted(self): self.setUpLoggedInUser() @@ -81,16 +141,15 @@ class TestsGuardian(APITestCase): body = {'some': "some_new"} response = self.client.patch('/permissionless-dummys/{}/'.format(dummy_without.slug), data=json.dumps(body), content_type='application/ld+json') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 404) # test that custom permissions are returned on a model def test_custom_permissions(self): self.setUpLoggedInUser() - self.setUpGuardianDummyWithPerms(['custom_permission']) + self.setUpGuardianDummyWithPerms(['custom_permission', 'view']) - permissions = LDPPermissions() - result = permissions.user_permissions(self.user, self.dummy) - self.assertIn('custom_permission', result) + response = self.client.get('/permissionless-dummys/{}/'.format(self.dummy.slug)) + self.assertIn('custom_permission', response.data['permissions']) # test that duplicate permissions aren't returned def test_no_duplicate_permissions(self): @@ -100,39 +159,8 @@ class TestsGuardian(APITestCase): assign_perm('view_' + model_name, self.user, dummy) - permissions = LDPPermissions() - result = permissions.user_permissions(self.user, dummy) - self.assertEqual(result.count('view'), 1) - - # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/297 - '''def test_list_dummy_exception(self): - self.setUpLoggedInUser() - # I have permission on a permissionless dummy, but not in general - dummy_a = self._get_dummy_with_perms() - dummy_b = self._get_dummy_with_perms(['view']) - response = self.client.get('/permissionless-dummys/') - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['ldp:contains']), 1) - self.assertNotIn(response.data['ldp:contains'], dummy_a.urlid) - self.assertIn(response.data['ldp:contains'], dummy_b.urlid)''' - - def test_list_dummy_exception_nested_view(self): - self.setUpLoggedInUser() - parent = LDPDummy.objects.create(some="test") - # two dummies, one I have permission to view and one I don't - dummy_a = self._get_dummy_with_perms(parent=parent) - dummy_b = self._get_dummy_with_perms(['view'], parent) - response = self.client.get('/ldpdummys/{}/anons/'.format(parent.pk)) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['ldp:contains']), 1) - - def test_list_dummy_exception_nested_serializer(self): - self.setUpLoggedInUser() - parent = LDPDummy.objects.create(some="test") - # two dummies, one I have permission to view and one I don't - dummy_a = self._get_dummy_with_perms(parent=parent) - dummy_b = self._get_dummy_with_perms(['view'], parent) - response = self.client.get('/ldpdummys/{}/'.format(parent.pk)) + response = self.client.get('/dummys/{}/'.format(dummy.slug)) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['anons']['ldp:contains']), 1) - + self.assertIn('view', response.data['permissions']) + view_perms = [perm for perm in response.data['permissions'] if perm == 'view'] + self.assertEqual(len(view_perms), 1) diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py index 2f61261c..7e36d0cb 100644 --- a/djangoldp/tests/tests_user_permissions.py +++ b/djangoldp/tests/tests_user_permissions.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission, Group +from djangoldp.serializers import LDListMixin, LDPSerializer from rest_framework.test import APIClient, APITestCase from .models import JobOffer, LDPDummy, PermissionlessDummy @@ -13,6 +14,8 @@ class TestUserPermissions(APITestCase): self.client = APIClient(enforce_csrf_checks=True) self.client.force_authenticate(user=self.user) self.job = JobOffer.objects.create(title="job", slug="slug1") + LDListMixin.to_representation_cache.reset() + LDPSerializer.to_representation_cache.reset() def setUpGroup(self): self.group = Group.objects.create(name='Test') @@ -25,24 +28,78 @@ class TestUserPermissions(APITestCase): response = self.client.get('/job-offers/') self.assertEqual(response.status_code, 200) - # list - I do not have permission from the model, but I do have permission via a Group I am assigned - # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/291 + # TODO: list - I do not have permission from the model, but I do have permission via a Group I am assigned + # https://git.startinblox.com/djangoldp-packages/djangoldp/issues/291 '''def test_group_list_access(self): self.setUpGroup() + dummy = PermissionlessDummy.objects.create() response = self.client.get('/permissionless-dummys/') - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 0) + + LDListMixin.to_representation_cache.reset() + LDPSerializer.to_representation_cache.reset() self.user.groups.add(self.group) self.user.save() response = self.client.get('/permissionless-dummys/') - self.assertEqual(response.status_code, 200)''' + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 1) - # TODO: repeat of the above test on nested field - '''def test_group_list_access_nested(self): + # repeat of the above test on nested field + def test_group_list_access_nested_field(self): self.setUpGroup() parent = LDPDummy.objects.create() - dummy = PermissionlessDummy.objects.create(parent=parent)''' + PermissionlessDummy.objects.create(parent=parent) + + response = self.client.get('/ldpdummys/{}/'.format(parent.pk)) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['anons']['ldp:contains']), 0) + + LDListMixin.to_representation_cache.reset() + LDPSerializer.to_representation_cache.reset() + + self.user.groups.add(self.group) + self.user.save() + response = self.client.get('/ldpdummys/{}/'.format(parent.pk)) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['anons']['ldp:contains']), 1) + + # repeat of the test on a nested viewset + def test_group_list_access_nested_viewset(self): + self.setUpGroup() + parent = LDPDummy.objects.create() + PermissionlessDummy.objects.create(parent=parent) + + response = self.client.get('/ldpdummys/{}/anons/'.format(parent.pk)) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 0) + + LDListMixin.to_representation_cache.reset() + LDPSerializer.to_representation_cache.reset() + + self.user.groups.add(self.group) + self.user.save() + response = self.client.get('/ldpdummys/{}/anons/'.format(parent.pk)) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 1) + + # repeat for object-specific request + def test_group_object_access(self): + self.setUpGroup() + dummy = PermissionlessDummy.objects.create() + + response = self.client.get('/permissionless-dummys/{}'.format(dummy)) + self.assertEqual(response.status_code, 404) + + LDListMixin.to_representation_cache.reset() + LDPSerializer.to_representation_cache.reset() + + self.user.groups.add(self.group) + self.user.save() + response = self.client.get('/permissionless-dummys/{}/'.format(dummy)) + self.assertEqual(response.status_code, 200)''' def test_get_1_for_authenticated_user(self): response = self.client.get('/job-offers/{}/'.format(self.job.slug)) diff --git a/djangoldp/views.py b/djangoldp/views.py index 5659f3cd..254a8c30 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -452,7 +452,21 @@ class LDPViewSet(LDPViewSetGenerator): ''' return True + def check_model_permissions(self, request): + """ + Check if the request should be permitted when the model-level permissions matter (generally just for creating an object) + Raises an appropriate exception if the request is not permitted. + """ + for permission in self.get_permissions(): + if not permission.has_model_permission(request, self): + self.permission_denied( + request, + message=getattr(permission, 'message', None), + code=getattr(permission, 'code', None) + ) + def create(self, request, *args, **kwargs): + self.check_model_permissions(request) serializer = self.get_write_serializer(data=request.data) serializer.is_valid(raise_exception=True) if not self.is_safe_create(request.user, serializer.validated_data): -- GitLab From 50179cc541290f3eabff4a645cb35e004b436e39 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 5 Jan 2021 15:56:19 +0000 Subject: [PATCH 02/31] update: extended tests for permissions --- djangoldp/serializers.py | 2 +- djangoldp/tests/djangoldp_urls.py | 3 +- djangoldp/tests/models.py | 20 +++ .../tests/tests_anonymous_permissions.py | 2 + djangoldp/tests/tests_get.py | 11 ++ djangoldp/tests/tests_guardian.py | 5 +- djangoldp/tests/tests_user_permissions.py | 152 +++++++++++++++++- djangoldp/views.py | 5 +- 8 files changed, 190 insertions(+), 10 deletions(-) diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index aefce2b7..e4689829 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -659,7 +659,7 @@ class LDPSerializer(HyperlinkedModelSerializer): def internal_create(self, validated_data, model): validated_data = self.resolve_fk_instances(model, validated_data, True) - # build tuples list of nested_field keys and their values + # build tuples list of nested_field keys and their values. All list values are considered nested fields nested_fields = [] nested_list_fields_name = list(filter(lambda key: isinstance(validated_data[key], list), validated_data)) for field_name in nested_list_fields_name: diff --git a/djangoldp/tests/djangoldp_urls.py b/djangoldp/tests/djangoldp_urls.py index 05d28e6f..4f21ed75 100644 --- a/djangoldp/tests/djangoldp_urls.py +++ b/djangoldp/tests/djangoldp_urls.py @@ -1,7 +1,7 @@ from django.conf.urls import re_path from djangoldp.permissions import LDPPermissions -from djangoldp.tests.models import Skill, JobOffer, Message, Conversation, Dummy, PermissionlessDummy, Task, DateModel +from djangoldp.tests.models import Skill, JobOffer, Message, Conversation, Dummy, PermissionlessDummy, Task, DateModel, LDPDummy from djangoldp.views import LDPViewSet urlpatterns = [ @@ -10,6 +10,7 @@ urlpatterns = [ re_path(r'^tasks/', LDPViewSet.urls(model=Task, permission_classes=[LDPPermissions])), re_path(r'^dates/', LDPViewSet.urls(model=DateModel, permission_classes=[LDPPermissions])), re_path(r'^dummys/', LDPViewSet.urls(model=Dummy, permission_classes=[LDPPermissions], lookup_field='slug',)), + re_path(r'^ldpdummys/', LDPViewSet.urls(model=LDPDummy, permission_classes=[LDPPermissions], nested_fields=['anons'])), re_path(r'^permissionless-dummys/', LDPViewSet.urls(model=PermissionlessDummy, permission_classes=[LDPPermissions], lookup_field='slug',)), ] diff --git a/djangoldp/tests/models.py b/djangoldp/tests/models.py index 4c94988a..15074558 100644 --- a/djangoldp/tests/models.py +++ b/djangoldp/tests/models.py @@ -87,6 +87,21 @@ class Resource(Model): rdf_type = 'hd:Resource' +# a resource in which only the owner has permissions (for testing owner permissions) +class OwnedResource(Model): + description = models.CharField(max_length=255, blank=True, null=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name="owned_resources", + on_delete=models.CASCADE) + + class Meta(Model.Meta): + anonymous_perms = [] + authenticated_perms = [] + owner_perms = ['view', 'delete', 'add', 'change', 'control'] + owner_field = 'user' + serializer_fields = ['@id', 'description', 'user'] + depth = 1 + + class UserProfile(Model): description = models.CharField(max_length=255, blank=True, null=True) user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='userprofile', on_delete=models.CASCADE) @@ -232,6 +247,11 @@ class Task(models.Model): owner_perms = ['inherit', 'change', 'delete', 'control'] +class ModelTask(Model, Task): + class Meta(Model.Meta): + pass + + class Project(Model): description = models.CharField(max_length=255, null=True, blank=False) team = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, related_name='projects') diff --git a/djangoldp/tests/tests_anonymous_permissions.py b/djangoldp/tests/tests_anonymous_permissions.py index 2bd48b6a..76854d20 100644 --- a/djangoldp/tests/tests_anonymous_permissions.py +++ b/djangoldp/tests/tests_anonymous_permissions.py @@ -24,6 +24,8 @@ class TestAnonymousUserPermissions(TestCase): response = self.client.post('/job-offers/', data=json.dumps(post), content_type='application/ld+json') self.assertEqual(response.status_code, 403) + # TODO: test POST request for anonymous user where it's allowed + def test_put_request_for_anonymousUser(self): body = {'title':"job_updated"} response = self.client.put('/job-offers/{}/'.format(self.job.pk), data=json.dumps(body), diff --git a/djangoldp/tests/tests_get.py b/djangoldp/tests/tests_get.py index e772af33..b6530bbd 100644 --- a/djangoldp/tests/tests_get.py +++ b/djangoldp/tests/tests_get.py @@ -116,6 +116,17 @@ class TestGET(APITestCase): self.assertEqual(response.data['ldp:contains'][1]['@id'], distant_batch.urlid) self.assertEqual(len(response.data['ldp:contains'][1].items()), 2) + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/335 + # test getting a route with multiple nested fields (/job-offers/X/skills/Y/) + '''def test_get_twice_nested(self): + job = JobOffer.objects.create(title="job", slug="slug1") + skill = Skill.objects.create(title='old', obligatoire='old', slug='skill1') + job.skills.add(skill) + self.assertEqual(job.skills.count(), 1) + + response = self.client.get('/job-offers/{}/skills/{}/'.format(job.slug, skill.slug)) + self.assertEqual(response.status_code, 200)''' + def test_serializer_excludes(self): date = DateModel.objects.create(excluded='test', value=datetime.now()) response = self.client.get('/dates/{}/'.format(date.pk), content_type='application/ld+json') diff --git a/djangoldp/tests/tests_guardian.py b/djangoldp/tests/tests_guardian.py index bb2dbe1b..38d3f618 100644 --- a/djangoldp/tests/tests_guardian.py +++ b/djangoldp/tests/tests_guardian.py @@ -60,7 +60,6 @@ class TestsGuardian(APITestCase): self.setUpGuardianDummyWithPerms() response = self.client.get('/permissionless-dummys/') # I have no object permissions - I should receive a 200 with an empty list - # TODO: masking this view altogether, but allowing exceptions on model/group-levels self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data['ldp:contains']), 0) @@ -143,6 +142,8 @@ class TestsGuardian(APITestCase): content_type='application/ld+json') self.assertEqual(response.status_code, 404) + # TODO: PUT container of many objects approved on specific resource for which I do not have _model_ permissions + # test that custom permissions are returned on a model def test_custom_permissions(self): self.setUpLoggedInUser() @@ -164,3 +165,5 @@ class TestsGuardian(APITestCase): self.assertIn('view', response.data['permissions']) view_perms = [perm for perm in response.data['permissions'] if perm == 'view'] self.assertEqual(len(view_perms), 1) + + # TODO: attempting to migrate my object permissions by changing FK reference diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py index 7e36d0cb..099dbf0d 100644 --- a/djangoldp/tests/tests_user_permissions.py +++ b/djangoldp/tests/tests_user_permissions.py @@ -1,8 +1,9 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission, Group +from django.conf import settings from djangoldp.serializers import LDListMixin, LDPSerializer from rest_framework.test import APIClient, APITestCase -from .models import JobOffer, LDPDummy, PermissionlessDummy +from djangoldp.tests.models import JobOffer, LDPDummy, PermissionlessDummy, Skill, UserProfile, OwnedResource import json @@ -99,19 +100,43 @@ class TestUserPermissions(APITestCase): self.user.groups.add(self.group) self.user.save() response = self.client.get('/permissionless-dummys/{}/'.format(dummy)) - self.assertEqual(response.status_code, 200)''' + self.assertEqual(response.status_code, 200) + + # TODO: test for POST scenario + # TODO: test for PUT scenario + # TODO: test for DELETE scenario + ''' def test_get_1_for_authenticated_user(self): response = self.client.get('/job-offers/{}/'.format(self.job.slug)) self.assertEqual(response.status_code, 200) def test_post_request_for_authenticated_user(self): - post = {'title': "job_created", "slug": 'slug1'} + post = {'title': "job_created", "slug": 'slug2'} response = self.client.post('/job-offers/', data=json.dumps(post), content_type='application/ld+json') self.assertEqual(response.status_code, 201) + # denied because I don't have model permissions + def test_post_request_denied_model_perms(self): + data = {'some': 'title'} + response = self.client.post('/permissionless-dummys/', data=json.dumps(data), content_type='application/ld+json') + self.assertEqual(response.status_code, 403) + + def test_post_nested_view_authorized(self): + data = { "title": "new skill", "obligatoire": "okay" } + response = self.client.post('/job-offers/{}/skills/'.format(self.job.slug), data=json.dumps(data), + content_type='application/ld+json') + self.assertEqual(response.status_code, 201) + + def test_post_nested_view_denied_model_perms(self): + parent = LDPDummy.objects.create(some='parent') + data = { "some": "title" } + response = self.client.post('/ldpdummys/{}/anons/'.format(parent.pk), data=json.dumps(data), + content_type='application/ld+json') + self.assertEqual(response.status_code, 403) + def test_put_request_for_authenticated_user(self): - body = {'title':"job_updated"} + body = {'https://happy-dev.fr/owl/#title':"job_updated"} response = self.client.put('/job-offers/{}/'.format(self.job.slug), data=json.dumps(body), content_type='application/ld+json') self.assertEqual(response.status_code, 200) @@ -120,3 +145,122 @@ class TestUserPermissions(APITestCase): response = self.client.patch('/job-offers/' + str(self.job.slug) + "/", content_type='application/ld+json') self.assertEqual(response.status_code, 200) + + def test_put_request_denied_model_perms(self): + dummy = PermissionlessDummy.objects.create(some='some', slug='slug') + data = {'some': 'new'} + response = self.client.put('/permissionless-dummys/{}/'.format(dummy.slug), data=json.dumps(data), + content_type='application/ld+json') + self.assertEqual(response.status_code, 404) + + def test_put_nested_view_denied_model_perms(self): + parent = LDPDummy.objects.create(some='parent') + child = PermissionlessDummy.objects.create(some='child', slug='child', parent=parent) + data = {"some": "new"} + response = self.client.put('/ldpdummys/{}/anons/{}/'.format(parent.pk, child.slug), data=json.dumps(data), + content_type='application/ld+json') + self.assertEqual(response.status_code, 404) + + def test_patch_nested_container_attach_existing_resource_permission_denied(self): + '''I am attempting to add a resource which I should not know exists''' + parent = LDPDummy.objects.create(some='parent') + dummy = PermissionlessDummy.objects.create(some='some', slug='slug') + data = { + 'anons': [ + {'@id': '{}/permissionless-dummys/{}/'.format(settings.SITE_URL, dummy.slug), 'slug': dummy.slug} + ] + } + response = self.client.patch('/ldpdummys/{}/'.format(parent.pk), data=json.dumps(data), content_type='application/ld+json') + self.assertEqual(response.status_code, 404) + + # variations on previous tests with an extra level of depth + # TODO + def test_post_nested_container_twice_nested_permission_denied(self): + pass + + # TODO + def test_put_nested_container_twice_nested_permission_denied(self): + pass + + # TODO: repeat of the above where it is authorized because I have permission through my Group + # https://git.startinblox.com/djangoldp-packages/djangoldp/issues/291 + + def test_put_request_change_urlid_rejected(self): + self.assertEqual(JobOffer.objects.count(), 1) + body = {'@id': "ishouldnotbeabletochangethis"} + response = self.client.put('/job-offers/{}/'.format(self.job.slug), data=json.dumps(body), + content_type='application/ld+json') + # TODO: this is failing quietly + # https://git.happy-dev.fr/startinblox/solid-spec/issues/14 + self.assertEqual(response.status_code, 200) + self.assertEqual(JobOffer.objects.count(), 1) + self.assertFalse(JobOffer.objects.filter(urlid=body['@id']).exists()) + + def test_put_request_change_pk_rejected(self): + self.assertEqual(JobOffer.objects.count(), 1) + body = {'pk': 2} + response = self.client.put('/job-offers/{}/'.format(self.job.slug), data=json.dumps(body), + content_type='application/ld+json') + # TODO: this is failing quietly + # https://git.happy-dev.fr/startinblox/solid-spec/issues/14 + self.assertEqual(response.status_code, 200) + self.assertEqual(JobOffer.objects.count(), 1) + self.assertFalse(JobOffer.objects.filter(pk=body['pk']).exists()) + + # tests that I receive a list of objects for which I am owner, filtering those for which I am not + def test_list_owned_resources(self): + my_resource = OwnedResource.objects.create(description='test', user=self.user) + another_user = get_user_model().objects.create_user(username='test', email='test@test.com', password='test') + their_resource = OwnedResource.objects.create(description='another test', user=another_user) + + response = self.client.get('/ownedresources/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 1) + self.assertEqual(response.data['ldp:contains'][0]['@id'], my_resource.urlid) + + # I do not have model permissions as an authenticated user, but I am the resources' owner + def test_get_owned_resource(self): + my_resource = OwnedResource.objects.create(description='test', user=self.user) + another_user = get_user_model().objects.create_user(username='test', email='test@test.com', password='test') + their_resource = OwnedResource.objects.create(description='another test', user=another_user) + + response = self.client.get('/ownedresources/{}/'.format(my_resource.pk)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['@id'], my_resource.urlid) + + # I have permission to view this resource + response = self.client.patch('/ownedresources/{}/'.format(their_resource.pk)) + self.assertEqual(response.status_code, 404) + + def test_patch_owned_resource(self): + my_profile = UserProfile.objects.create(user=self.user, slug=self.user.username, description='about me') + another_user = get_user_model().objects.create_user(username='test', email='test@test.com', password='test') + their_profile = UserProfile.objects.create(user=another_user, slug=another_user.username, description='about') + + response = self.client.patch('/userprofiles/{}/'.format(my_profile.slug)) + self.assertEqual(response.status_code, 200) + + response = self.client.patch('/userprofiles/{}/'.format(their_profile.slug)) + # TODO: technically this should be 403, since I do have permission to view their user profile + # https://git.startinblox.com/djangoldp-packages/djangoldp/issues/336 + self.assertEqual(response.status_code, 404) + + def test_delete_owned_resource(self): + my_resource = OwnedResource.objects.create(description='test', user=self.user) + another_user = get_user_model().objects.create_user(username='test', email='test@test.com', password='test') + their_resource = OwnedResource.objects.create(description='another test', user=another_user) + + response = self.client.delete('/ownedresources/{}/'.format(my_resource.pk)) + self.assertEqual(response.status_code, 204) + + response = self.client.delete('/ownedresources/{}/'.format(their_resource.pk)) + self.assertEqual(response.status_code, 404) + + # TODO: I have model (or object?) permissions. Attempt to make myself owner and thus upgrade my permissions + # TODO: I have owner permissions. Attempt to make myself the owner of another resource by changing the FK ref + # TODO: repeat of the above but upgrading another users' permissions + + # TODO: test models with custom permissions classes active (test that it overrides default behaviour) + + # TODO: test superuser permissions + # https://git.startinblox.com/djangoldp-packages/djangoldp/issues/295 diff --git a/djangoldp/views.py b/djangoldp/views.py index 8dc6aa26..d35b7ede 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -481,11 +481,10 @@ class LDPViewSet(LDPViewSetGenerator): Raises an appropriate exception if the request is not permitted. """ for permission in self.get_permissions(): - if not permission.has_model_permission(request, self): + if hasattr(permission, 'has_model_permission') and not permission.has_model_permission(request, self): self.permission_denied( request, - message=getattr(permission, 'message', None), - code=getattr(permission, 'code', None) + message=getattr(permission, 'message', None) ) def create(self, request, *args, **kwargs): -- GitLab From f94a95e73ddb653c39ae78cbf4b3a5a6c60c043b Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 5 Jan 2021 16:03:50 +0000 Subject: [PATCH 03/31] update: removed permissions cache --- djangoldp/models.py | 1 - djangoldp/permissions.py | 55 +---------------------------- djangoldp/tests/models.py | 5 --- djangoldp/tests/settings_default.py | 1 - djangoldp/tests/tests_guardian.py | 2 -- 5 files changed, 1 insertion(+), 63 deletions(-) diff --git a/djangoldp/models.py b/djangoldp/models.py index 65deda6d..48d6c17d 100644 --- a/djangoldp/models.py +++ b/djangoldp/models.py @@ -443,7 +443,6 @@ def auto_urlid(sender, instance, **kwargs): @receiver([pre_save, pre_delete, m2m_changed]) def invalidate_caches(instance, **kwargs): from djangoldp.serializers import LDListMixin, LDPSerializer - LDPPermissions.invalidate_cache() LDListMixin.to_representation_cache.reset() if hasattr(instance, 'urlid'): diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 2b7cebff..f764cb41 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -71,29 +71,6 @@ class LDPBasePermission(DjangoObjectPermissions): return True -class CachedLDPBasePermission(LDPBasePermission): - perms_cache = { - 'time': time.time() - } - with_cache = getattr(settings, 'PERMISSIONS_CACHE', True) - - @classmethod - def invalidate_cache(cls): - cls.perms_cache = { - 'time': time.time() - } - - @classmethod - def refresh_cache(cls): - if (time.time() - cls.perms_cache['time']) > 5: - cls.invalidate_cache() - - def _get_cache_key(self, model_name, user, obj): - user_key = 'None' if user is None else user.id - obj_key = 'None' if obj is None else obj.id - return 'User{}{}{}'.format(user_key, model_name, obj_key) - - class OwnerAuthAnonPermissions(LDPBasePermission): # *DEFAULT* model-level permissions for anon, auth and owner statuses anonymous_perms = ['view'] @@ -154,35 +131,5 @@ class LDPObjectLevelPermissions(LDPBasePermission): return perms -class LDPPermissions(LDPObjectLevelPermissions, OwnerAuthAnonPermissions, CachedLDPBasePermission): - filter_backends = [LDPPermissionsFilterBackend] - - -''' -class OldLDPPermissions(DjangoObjectPermissions): +class LDPPermissions(LDPObjectLevelPermissions, OwnerAuthAnonPermissions): filter_backends = [LDPPermissionsFilterBackend] - - def user_permissions(self, user, obj_or_model, obj=None): - """ - Filter user permissions for a model class - """ - self.refresh_cache() - # this may be a permission for the model class, or an instance - if isinstance(obj_or_model, ModelBase): - model = obj_or_model - else: - obj = obj_or_model - model = obj_or_model.__class__ - model_name = model._meta.model_name - - perms_cache_key = self.cache_key(model, obj, user) - if self.with_cache and perms_cache_key in self.perms_cache: - return self.perms_cache[perms_cache_key] - - # return permissions - using set to avoid duplicates - perms = self.get_model_level_perms(model, user, obj) - - self.perms_cache[perms_cache_key] = list(perms) - - return self.perms_cache[perms_cache_key] -''' diff --git a/djangoldp/tests/models.py b/djangoldp/tests/models.py index 15074558..80484b6a 100644 --- a/djangoldp/tests/models.py +++ b/djangoldp/tests/models.py @@ -285,8 +285,3 @@ class MyAbstractModel(Model): permission_classes = [LDPPermissions] abstract = True rdf_type = "wow:defaultrdftype" - - -@receiver(post_save, sender=User) -def update_perms(sender, instance, created, **kwargs): - LDPPermissions.invalidate_cache() diff --git a/djangoldp/tests/settings_default.py b/djangoldp/tests/settings_default.py index ceacf534..e152d4d6 100644 --- a/djangoldp/tests/settings_default.py +++ b/djangoldp/tests/settings_default.py @@ -23,5 +23,4 @@ server: SEND_BACKLINKS: false GUARDIAN_AUTO_PREFETCH: true SERIALIZER_CACHE: false - PERMISSIONS_CACHE: false """ diff --git a/djangoldp/tests/tests_guardian.py b/djangoldp/tests/tests_guardian.py index 38d3f618..fda42658 100644 --- a/djangoldp/tests/tests_guardian.py +++ b/djangoldp/tests/tests_guardian.py @@ -14,7 +14,6 @@ class TestsGuardian(APITestCase): def setUp(self): self.client = APIClient(enforce_csrf_checks=True) - LDPPermissions.invalidate_cache() LDListMixin.to_representation_cache.reset() LDPSerializer.to_representation_cache.reset() @@ -25,7 +24,6 @@ class TestsGuardian(APITestCase): self.user.groups.add(self.group) self.user.save() self.client.force_authenticate(user=self.user) - LDPPermissions.invalidate_cache() LDListMixin.to_representation_cache.reset() LDPSerializer.to_representation_cache.reset() -- GitLab From a547a57ca6ed5ecd1d527e9fd02c6c1efe2b8299 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Fri, 8 Jan 2021 13:56:05 +0000 Subject: [PATCH 04/31] update: extended tests_update.py --- djangoldp/tests/models.py | 12 +- djangoldp/tests/tests_update.py | 435 +++++++++------------- djangoldp/tests/tests_user_permissions.py | 20 +- 3 files changed, 194 insertions(+), 273 deletions(-) diff --git a/djangoldp/tests/models.py b/djangoldp/tests/models.py index 80484b6a..5a1446da 100644 --- a/djangoldp/tests/models.py +++ b/djangoldp/tests/models.py @@ -1,8 +1,6 @@ from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models -from django.db.models.signals import post_save -from django.dispatch import receiver from django.utils.datetime_safe import date from djangoldp.models import Model @@ -31,9 +29,9 @@ class Skill(Model): class Meta(Model.Meta): anonymous_perms = ['view'] - authenticated_perms = ['inherit', 'add'] - owner_perms = ['inherit', 'change', 'delete', 'control'] - serializer_fields = ["@id", "title", "recent_jobs", "slug"] + authenticated_perms = ['inherit', 'add', 'change'] + owner_perms = ['inherit', 'delete', 'control'] + serializer_fields = ["@id", "title", "recent_jobs", "slug", "obligatoire"] lookup_field = 'slug' rdf_type = 'hd:skill' @@ -194,8 +192,8 @@ class Invoice(Model): class Meta(Model.Meta): depth = 2 anonymous_perms = ['view'] - authenticated_perms = ['inherit', 'add'] - owner_perms = ['inherit', 'change', 'delete', 'control'] + authenticated_perms = ['inherit', 'add', 'change'] + owner_perms = ['inherit', 'delete', 'control'] class Circle(Model): diff --git a/djangoldp/tests/tests_update.py b/djangoldp/tests/tests_update.py index 6be021bc..e265bd2f 100644 --- a/djangoldp/tests/tests_update.py +++ b/djangoldp/tests/tests_update.py @@ -6,8 +6,8 @@ from rest_framework.test import APIRequestFactory, APIClient from rest_framework.utils import json from djangoldp.serializers import LDPSerializer, LDListMixin -from djangoldp.tests.models import Post, UserProfile, Resource, Circle -from djangoldp.tests.models import Skill, JobOffer, Conversation, Message, Project +from djangoldp.tests.models import Post, UserProfile, Resource, Circle, CircleMember, Invoice, Batch, Task, Skill, JobOffer, \ + Conversation, Message, Project, NotificationSetting class Update(TestCase): @@ -21,270 +21,61 @@ class Update(TestCase): LDListMixin.to_representation_cache.reset() LDPSerializer.to_representation_cache.reset() - def tearDown(self): - pass - - def test_update(self): - skill = Skill.objects.create(title="to drop", obligatoire="obligatoire", slug="slug1") - skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="slug2") - skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="slug3") - job1 = JobOffer.objects.create(title="job test") - job1.skills.add(skill) - - job = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job1.slug), - "title": "job test updated", - "skills": { - "ldp:contains": [ - {"title": "new skill", "obligatoire": "okay"}, - {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill1.slug)}, - {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill2.slug), "title": "skill2 UP"}, - ]} - } - - meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills")} - - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=job, instance=job1) - serializer.is_valid() - result = serializer.save() - - self.assertEquals(result.title, "job test updated") - self.assertIs(result.skills.count(), 3) - skills = result.skills.all().order_by('title') - self.assertEquals(skills[0].title, "new skill") # new skill - self.assertEquals(skills[1].title, "skill1") # no change - self.assertEquals(skills[2].title, "skill2 UP") # title updated - - def test_update_graph(self): - skill = Skill.objects.create(title="to drop", obligatoire="obligatoire", slug="slug1") - skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="slug2") - skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="slug3") - job1 = JobOffer.objects.create(title="job test", slug="slug4") - job1.skills.add(skill) - - job = {"@graph": - [ - { - "@id": "{}/job-offers/{}/".format(settings.BASE_URL, job1.slug), - "title": "job test updated", - "skills": { - "ldp:contains": [ - {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill1.slug)}, - {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill2.slug)}, - {"@id": "_.123"}, - ]} - }, - { - "@id": "_.123", - "title": "new skill", - "obligatoire": "okay" - }, - { - "@id": "{}/skills/{}/".format(settings.BASE_URL, skill1.slug), - }, - { - "@id": "{}/skills/{}/".format(settings.BASE_URL, skill2.slug), - "title": "skill2 UP" - } - ] - } - - meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills")} - - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=job, instance=job1) - serializer.is_valid() - result = serializer.save() - - skills = result.skills.all().order_by('title') - - self.assertEquals(result.title, "job test updated") - self.assertIs(result.skills.count(), 3) - self.assertEquals(skills[0].title, "new skill") # new skill - self.assertEquals(skills[1].title, "skill1") # no change - self.assertEquals(skills[2].title, "skill2 UP") # title updated - - def test_update_graph_2(self): - skill = Skill.objects.create(title="to drop", obligatoire="obligatoire", slug="slug") - skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="slug1") - skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="slug2") - job1 = JobOffer.objects.create(title="job test", slug="slug1") - job1.skills.add(skill) - - job = {"@graph": - [ - { - "@id": "{}/job-offers/{}/".format(settings.BASE_URL, job1.slug), - "title": "job test updated", - "skills": { - "@id": "{}/job-offers/{}/skills/".format(settings.BASE_URL, job1.slug) - } - }, - { - "@id": "_.123", - "title": "new skill", - "obligatoire": "okay" - }, - { - "@id": "{}/skills/{}/".format(settings.BASE_URL, skill1.slug), - }, - { - "@id": "{}/skills/{}/".format(settings.BASE_URL, skill2.slug), - "title": "skill2 UP" - }, - { - '@id': "{}/job-offers/{}/skills/".format(settings.BASE_URL, job1.slug), + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/326 + ''' + def test_update_container_append_resource(self): + pre_existing_skill_a = Skill.objects.create(title="to keep", obligatoire="obligatoire", slug="slug1") + pre_existing_skill_b = Skill.objects.create(title="to keep", obligatoire="obligatoire", slug="slug2") + job = JobOffer.objects.create(title="job test") + job.skills.add(pre_existing_skill_a) + job.skills.add(pre_existing_skill_b) + + post = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job.slug), + "skills": { "ldp:contains": [ - {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill1.slug)}, - {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill2.slug)}, - {"@id": "_.123"}, - ] + {"title": "new skill", "obligatoire": "okay"}, + {"@id": "{}/skills/{}/".format(settings.BASE_URL, pre_existing_skill_b.slug), "title": "z"}, + ]} } - ] - } - - meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=job, instance=job1) - serializer.is_valid() - result = serializer.save() + response = self.client.patch('/job-offers/{}/'.format(job.slug), + data=json.dumps(post), + content_type='application/ld+json') + self.assertEquals(response.status_code, 200) - skills = result.skills.all().order_by('title') - - self.assertEquals(result.title, "job test updated") - self.assertIs(result.skills.count(), 3) + self.assertEquals(response.data['title'], job.title) + self.assertIs(job.skills.count(), 3) + skills = job.skills.all().order_by('title') self.assertEquals(skills[0].title, "new skill") # new skill - self.assertEquals(skills[1].title, "skill1") # no change - self.assertEquals(skills[2].title, "skill2 UP") # title updated - self.assertEquals(skill, skill._meta.model.objects.get(pk=skill.pk)) # title updated - - # TODO: test update with external urlid which doesn't exist - # TODO: test update with internal urlid which doesn't exist - # TODO: repeat of the above where the relationship is ForeignKey - # TODO: test update with internal urlid which refers to a different type of object entirely - # TODO: test update with internal urlid which refers to a container - - def test_update_list_with_reverse_relation(self): - user1 = get_user_model().objects.create() - conversation = Conversation.objects.create(description="Conversation 1", author_user=user1) - message1 = Message.objects.create(text="Message 1", conversation=conversation, author_user=user1) - message2 = Message.objects.create(text="Message 2", conversation=conversation, author_user=user1) - - json = {"@graph": [ - { - "@id": "{}/messages/{}/".format(settings.BASE_URL, message1.pk), - "text": "Message 1 UP" - }, - { - "@id": "{}/messages/{}/".format(settings.BASE_URL, message2.pk), - "text": "Message 2 UP" - }, - { - '@id': "{}/conversations/{}/".format(settings.BASE_URL, conversation.pk), - 'description': "Conversation 1 UP", - "message_set": [ - {"@id": "{}/messages/{}/".format(settings.BASE_URL, message1.pk)}, - {"@id": "{}/messages/{}/".format(settings.BASE_URL, message2.pk)}, - ] - } - ] - } - - meta_args = {'model': Conversation, 'depth': 2, 'fields': ("@id", "description", "message_set")} - - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('ConversationSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=json, instance=conversation) - serializer.is_valid() - result = serializer.save() - - messages = result.message_set.all().order_by('text') - - self.assertEquals(result.description, "Conversation 1 UP") - self.assertIs(result.message_set.count(), 2) - self.assertEquals(messages[0].text, "Message 1 UP") - self.assertEquals(messages[1].text, "Message 2 UP") - - def test_add_new_element_with_foreign_key_id(self): - user1 = get_user_model().objects.create() - conversation = Conversation.objects.create(description="Conversation 1", author_user=user1) - message1 = Message.objects.create(text="Message 1", conversation=conversation, author_user=user1) - message2 = Message.objects.create(text="Message 2", conversation=conversation, author_user=user1) - - json = {"@graph": [ - { - "@id": "{}/messages/{}/".format(settings.BASE_URL, message1.pk), - "text": "Message 1 UP", - "author_user": { - '@id': "{}/users/{}/".format(settings.BASE_URL, user1.pk) - } - }, - { - "@id": "{}/messages/{}/".format(settings.BASE_URL, message2.pk), - "text": "Message 2 UP", - "author_user": { - '@id': user1.urlid - } - }, - { - "@id": "_:b1", - "text": "Message 3 NEW", - "author_user": { - '@id': user1.urlid - } - }, - { - '@id': "{}/conversations/{}/".format(settings.BASE_URL, conversation.pk), - "author_user": { - '@id': user1.urlid - }, - 'description': "Conversation 1 UP", - 'message_set': { - "@id": "{}/conversations/{}/message_set/".format(settings.BASE_URL, conversation.pk) - } - }, - { - '@id': "{}/conversations/{}/message_set/".format(settings.BASE_URL, conversation.pk), - "ldp:contains": [ - {"@id": "{}/messages/{}/".format(settings.BASE_URL, message1.pk)}, - {"@id": "{}/messages/{}/".format(settings.BASE_URL, message2.pk)}, - {"@id": "_:b1"} - ] - } - ] - } - - meta_args = {'model': Conversation, 'depth': 2, 'fields': ("@id", "description", "message_set")} - - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('ConversationSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=json, instance=conversation) - serializer.is_valid() - result = serializer.save() - - messages = result.message_set.all().order_by('text') - - self.assertEquals(result.description, "Conversation 1 UP") - self.assertIs(result.message_set.count(), 3) - self.assertEquals(messages[0].text, "Message 1 UP") - self.assertEquals(messages[1].text, "Message 2 UP") - self.assertEquals(messages[2].text, "Message 3 NEW") + self.assertEquals(skills[1].title, pre_existing_skill_a.title) # old skill unchanged + self.assertEquals(skills[2].title, "z") # updated + self.assertEquals(skills[2].obligatoire, pre_existing_skill_b.obligatoire) # another field not updated + ''' def test_put_resource(self): - post = Post.objects.create(content="content") + skill = Skill.objects.create(title='original', obligatoire='original', slug='skill1') body = [{ - '@id': '{}/posts/{}/'.format(settings.BASE_URL, post.pk), - 'http://happy-dev.fr/owl/#content': "post content"}] - response = self.client.put('/posts/{}/'.format(post.pk), data=json.dumps(body), + '@id': '{}/skills/{}/'.format(settings.BASE_URL, skill.slug), + 'http://happy-dev.fr/owl/#title': "new", 'http://happy-dev.fr/owl/#obligatoire': "new"}] + response = self.client.put('/skills/{}/'.format(skill.slug), data=json.dumps(body), content_type='application/ld+json') self.assertEqual(response.status_code, 200) - self.assertEquals(response.data['content'], "post content") + self.assertEquals(response.data['title'], "new") + self.assertEquals(response.data['obligatoire'], "new") self.assertIn('location', response._headers) + def test_patch_resource(self): + skill = Skill.objects.create(title='original', obligatoire='original', slug='skill1') + body = { + '@id': '{}/skills/{}'.format(settings.BASE_URL, skill.slug), + 'http://happy-dev.fr/owl/#title': 'new' + } + response = self.client.patch('/skills/{}/'.format(skill.slug), data=json.dumps(body), + content_type='application/ld+json') + self.assertEqual(response.status_code, 200) + self.assertEquals(response.data['title'], "new") + self.assertEquals(response.data['obligatoire'], "original") + def test_create_sub_object_in_existing_object_with_existing_reverse_1to1_relation(self): user = get_user_model().objects.create(username="alex", password="test") profile = UserProfile.objects.create(user=user, description="user description") @@ -306,6 +97,22 @@ class Update(TestCase): self.assertEqual(response.status_code, 200) self.assertIn('userprofile', response.data) + def test_put_nonexistent_local_resource(self): + job = JobOffer.objects.create(title="job test") + + # contains internal urlid which refers to non-existent resource + body = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job.slug), + "skills": { + "ldp:contains": [ + {"@id": "{}/skills/404/".format(settings.BASE_URL)}, + ]} + } + + response = self.client.put('/job-offers/{}/'.format(job.slug), data=json.dumps(body), + content_type='application/ld+json') + self.assertEqual(response.status_code, 200) + self.assertEqual(Skill.objects.count(), 0) + def test_create_sub_object_in_existing_object_with_reverse_fk_relation(self): """ Doesn't work with depth = 0 on UserProfile Model. Should it be ? @@ -618,7 +425,123 @@ class Update(TestCase): content_type='application/ld+json') self.assertEqual(response.data['description'], "user update") - # TODO: test passing foreign key relation which I shouldn't have access/permission to - # TODO: test passing many-to-many relation in edit which isn't yet on my model + # unit tests for a specific bug: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/307 + def test_direct_boolean_field(self): + profile = UserProfile.objects.create(user=self.user) + setting = NotificationSetting.objects.create(user=profile, receiveMail=False) + body = { + 'http://happy-dev.fr/owl/#@id': setting.urlid, + 'receiveMail': True, + "@context": {"@vocab": "http://happy-dev.fr/owl/#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "ldp": "http://www.w3.org/ns/ldp#", + "foaf": "http://xmlns.com/foaf/0.1/", "name": "rdfs:label", + "acl": "http://www.w3.org/ns/auth/acl#", "permissions": "acl:accessControl", + "mode": "acl:mode", "geo": "http://www.w3.org/2003/01/geo/wgs84_pos#", "lat": "geo:lat", + "lng": "geo:long"} + } + + response = self.client.patch('/notificationsettings/{}/'.format(setting.pk), + data=json.dumps(body), + content_type='application/ld+json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['receiveMail'], True) + + def test_nested_container_boolean_field_no_slug(self): + profile = UserProfile.objects.create(user=self.user) + setting = NotificationSetting.objects.create(user=profile, receiveMail=False) + body = { + 'settings': { + 'http://happy-dev.fr/owl/#@id': setting.urlid, + 'receiveMail': True + }, + "@context": {"@vocab": "http://happy-dev.fr/owl/#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "ldp": "http://www.w3.org/ns/ldp#", + "foaf": "http://xmlns.com/foaf/0.1/", "name": "rdfs:label", + "acl": "http://www.w3.org/ns/auth/acl#", "permissions": "acl:accessControl", + "mode": "acl:mode", "geo": "http://www.w3.org/2003/01/geo/wgs84_pos#", "lat": "geo:lat", + "lng": "geo:long"} + } + + response = self.client.patch('/userprofiles/{}/'.format(profile.slug), + data=json.dumps(body), + content_type='application/ld+json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['settings']['receiveMail'], True) + + # variation where the lookup_field for NotificationSetting (pk) is provided + def test_nested_container_boolean_field_with_slug(self): + profile = UserProfile.objects.create(user=self.user) + setting = NotificationSetting.objects.create(user=profile, receiveMail=False) + body = { + 'settings': { + 'pk': setting.pk, + 'http://happy-dev.fr/owl/#@id': setting.urlid, + 'receiveMail': True + }, + "@context": {"@vocab": "http://happy-dev.fr/owl/#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "ldp": "http://www.w3.org/ns/ldp#", + "foaf": "http://xmlns.com/foaf/0.1/", "name": "rdfs:label", + "acl": "http://www.w3.org/ns/auth/acl#", "permissions": "acl:accessControl", + "mode": "acl:mode", "geo": "http://www.w3.org/2003/01/geo/wgs84_pos#", "lat": "geo:lat", + "lng": "geo:long"} + } + + response = self.client.patch('/userprofiles/{}/'.format(profile.slug), + data=json.dumps(body), + content_type='application/ld+json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['settings']['receiveMail'], True) + + def test_update_container_twice_nested_view(self): + invoice = Invoice.objects.create(title='test') + pre_existing_batch = Batch.objects.create(title='batch1', invoice=invoice) + pre_existing_task = Task.objects.create(title='task1', batch=pre_existing_batch) + + base_url = settings.BASE_URL + + body = { + "@id": "{}/invoices/{}/".format(base_url, invoice.pk), + "http://happy-dev.fr/owl/#title": "new", + "http://happy-dev.fr/owl/#batches": [ + { + "@id": "{}/batchs/{}/".format(base_url, pre_existing_batch.pk), + "http://happy-dev.fr/owl/#title": "new", + "http://happy-dev.fr/owl/#tasks": [ + { + "@id": "{}/tasks/{}/".format(base_url, pre_existing_task.pk), + "http://happy-dev.fr/owl/#title": "new" + }, + { + "http://happy-dev.fr/owl/#title": "tache 2" + } + ] + }, + { + "http://happy-dev.fr/owl/#title": "z", + } + ] + } + + response = self.client.put('/invoices/{}/'.format(invoice.pk), data=json.dumps(body), + content_type='application/ld+json') + self.assertEqual(response.status_code, 200) + self.assertEquals(response.data['title'], "new") + self.assertEquals(response.data['@id'], invoice.urlid) + + invoice = Invoice.objects.get(pk=invoice.pk) + self.assertIs(invoice.batches.count(), 2) + batches = invoice.batches.all().order_by('title') + self.assertEquals(batches[0].title, "new") + self.assertEquals(batches[0].urlid, pre_existing_batch.urlid) + self.assertEquals(batches[1].title, "z") + + self.assertIs(batches[0].tasks.count(), 2) + tasks = batches[0].tasks.all().order_by('title') + self.assertEquals(tasks[0].title, "new") + self.assertEquals(tasks[0].pk, pre_existing_task.pk) + self.assertEquals(tasks[1].title, "tache 2") diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py index 099dbf0d..032e2b11 100644 --- a/djangoldp/tests/tests_user_permissions.py +++ b/djangoldp/tests/tests_user_permissions.py @@ -112,25 +112,25 @@ class TestUserPermissions(APITestCase): self.assertEqual(response.status_code, 200) def test_post_request_for_authenticated_user(self): - post = {'title': "job_created", "slug": 'slug2'} + post = {'http://happy-dev.fr/owl/#title': "job_created", "http://happy-dev.fr/owl/#slug": 'slug2'} response = self.client.post('/job-offers/', data=json.dumps(post), content_type='application/ld+json') self.assertEqual(response.status_code, 201) # denied because I don't have model permissions def test_post_request_denied_model_perms(self): - data = {'some': 'title'} + data = {'http://happy-dev.fr/owl/#some': 'title'} response = self.client.post('/permissionless-dummys/', data=json.dumps(data), content_type='application/ld+json') self.assertEqual(response.status_code, 403) def test_post_nested_view_authorized(self): - data = { "title": "new skill", "obligatoire": "okay" } + data = { "http://happy-dev.fr/owl/#title": "new skill", "http://happy-dev.fr/owl/#obligatoire": "okay" } response = self.client.post('/job-offers/{}/skills/'.format(self.job.slug), data=json.dumps(data), content_type='application/ld+json') self.assertEqual(response.status_code, 201) def test_post_nested_view_denied_model_perms(self): parent = LDPDummy.objects.create(some='parent') - data = { "some": "title" } + data = { "http://happy-dev.fr/owl/#some": "title" } response = self.client.post('/ldpdummys/{}/anons/'.format(parent.pk), data=json.dumps(data), content_type='application/ld+json') self.assertEqual(response.status_code, 403) @@ -148,7 +148,7 @@ class TestUserPermissions(APITestCase): def test_put_request_denied_model_perms(self): dummy = PermissionlessDummy.objects.create(some='some', slug='slug') - data = {'some': 'new'} + data = {'http://happy-dev.fr/owl/#some': 'new'} response = self.client.put('/permissionless-dummys/{}/'.format(dummy.slug), data=json.dumps(data), content_type='application/ld+json') self.assertEqual(response.status_code, 404) @@ -156,7 +156,7 @@ class TestUserPermissions(APITestCase): def test_put_nested_view_denied_model_perms(self): parent = LDPDummy.objects.create(some='parent') child = PermissionlessDummy.objects.create(some='child', slug='child', parent=parent) - data = {"some": "new"} + data = {"http://happy-dev.fr/owl/#some": "new"} response = self.client.put('/ldpdummys/{}/anons/{}/'.format(parent.pk, child.slug), data=json.dumps(data), content_type='application/ld+json') self.assertEqual(response.status_code, 404) @@ -166,8 +166,8 @@ class TestUserPermissions(APITestCase): parent = LDPDummy.objects.create(some='parent') dummy = PermissionlessDummy.objects.create(some='some', slug='slug') data = { - 'anons': [ - {'@id': '{}/permissionless-dummys/{}/'.format(settings.SITE_URL, dummy.slug), 'slug': dummy.slug} + 'http://happy-dev.fr/owl/#anons': [ + {'@id': '{}/permissionless-dummys/{}/'.format(settings.SITE_URL, dummy.slug), 'http://happy-dev.fr/owl/#slug': dummy.slug} ] } response = self.client.patch('/ldpdummys/{}/'.format(parent.pk), data=json.dumps(data), content_type='application/ld+json') @@ -198,14 +198,14 @@ class TestUserPermissions(APITestCase): def test_put_request_change_pk_rejected(self): self.assertEqual(JobOffer.objects.count(), 1) - body = {'pk': 2} + body = {'http://happy-dev.fr/owl/#pk': 2} response = self.client.put('/job-offers/{}/'.format(self.job.slug), data=json.dumps(body), content_type='application/ld+json') # TODO: this is failing quietly # https://git.happy-dev.fr/startinblox/solid-spec/issues/14 self.assertEqual(response.status_code, 200) self.assertEqual(JobOffer.objects.count(), 1) - self.assertFalse(JobOffer.objects.filter(pk=body['pk']).exists()) + self.assertFalse(JobOffer.objects.filter(pk=body['http://happy-dev.fr/owl/#pk']).exists()) # tests that I receive a list of objects for which I am owner, filtering those for which I am not def test_list_owned_resources(self): -- GitLab From 906745ebf9cf115b91e97fcef487f29969703649 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Fri, 8 Jan 2021 13:59:08 +0000 Subject: [PATCH 05/31] syntax: renamed tests_save to tests_post --- djangoldp/tests/runner.py | 2 +- .../tests/{tests_save.py => tests_post.py} | 302 ++---------------- 2 files changed, 35 insertions(+), 269 deletions(-) rename djangoldp/tests/{tests_save.py => tests_post.py} (50%) diff --git a/djangoldp/tests/runner.py b/djangoldp/tests/runner.py index c4cb67af..26433496 100644 --- a/djangoldp/tests/runner.py +++ b/djangoldp/tests/runner.py @@ -20,10 +20,10 @@ failures = test_runner.run_tests([ 'djangoldp.tests.tests_settings', 'djangoldp.tests.tests_ldp_model', 'djangoldp.tests.tests_ldp_viewset', - 'djangoldp.tests.tests_save', 'djangoldp.tests.tests_user_permissions', 'djangoldp.tests.tests_guardian', 'djangoldp.tests.tests_anonymous_permissions', + 'djangoldp.tests.tests_post', 'djangoldp.tests.tests_update', 'djangoldp.tests.tests_auto_author', 'djangoldp.tests.tests_get', diff --git a/djangoldp/tests/tests_save.py b/djangoldp/tests/tests_post.py similarity index 50% rename from djangoldp/tests/tests_save.py rename to djangoldp/tests/tests_post.py index b9973266..4e0322d1 100644 --- a/djangoldp/tests/tests_save.py +++ b/djangoldp/tests/tests_post.py @@ -10,7 +10,7 @@ from djangoldp.tests.models import Skill, JobOffer, Invoice, LDPDummy, Resource, UserProfile, NotificationSetting -class Save(TestCase): +class PostTestCase(TestCase): def setUp(self): self.factory = APIRequestFactory() @@ -21,176 +21,6 @@ class Save(TestCase): LDListMixin.to_representation_cache.reset() LDPSerializer.to_representation_cache.reset() - def tearDown(self): - pass - - def test_save_m2m_graph_with_many_nested(self): - invoice = { - "@graph": [ - { - "@id": "./", - "batches": {"@id": "_:b381"}, - "title": "Nouvelle facture", - "date": "" - }, - { - "@id": "_:b381", - "tasks": {"@id": "_:b382"}, - "title": "Batch 1" - }, - { - "@id": "_:b382", - "title": "Tache 1" - } - ] - } - - meta_args = {'model': Invoice, 'depth': 2, 'fields': ("@id", "title", "batches", "date")} - - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('InvoiceSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=invoice) - serializer.is_valid() - result = serializer.save() - - self.assertEquals(result.title, "Nouvelle facture") - self.assertIs(result.batches.count(), 1) - self.assertEquals(result.batches.all()[0].title, "Batch 1") - self.assertIs(result.batches.all()[0].tasks.count(), 1) - self.assertEquals(result.batches.all()[0].tasks.all()[0].title, "Tache 1") - - def test_save_m2m(self): - skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="slug1") - skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="slug2") - - job = {"title": "job test", - "slug": "slug1", - "skills": { - "ldp:contains": [ - {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill1.slug)}, - {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill2.slug), "title": "skill2 UP"}, - {"title": "skill3", "obligatoire": "obligatoire", "slug": "slug3"}, - ]} - } - - meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} - - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=job) - serializer.is_valid() - result = serializer.save() - - self.assertEquals(result.title, "job test") - self.assertIs(result.skills.count(), 3) - self.assertEquals(result.skills.all()[0].title, "skill1") # no change - self.assertEquals(result.skills.all()[1].title, "skill2 UP") # title updated - self.assertEquals(result.skills.all()[2].title, "skill3") # creation on the fly - - # variation switching the http prefix of the BASE_URL in the request - @override_settings(BASE_URL='http://happy-dev.fr/') - def test_save_m2m_switch_base_url_prefix(self): - skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="slug1") - - job = {"title": "job test", - "slug": "slug1", - "skills": { - "ldp:contains": [ - {"@id": "https://happy-dev.fr/skills/{}/".format(skill1.slug)}, - ]} - } - - meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} - - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=job) - serializer.is_valid() - result = serializer.save() - - self.assertEquals(result.title, "job test") - self.assertIs(result.skills.count(), 1) - self.assertEquals(result.skills.all()[0].title, "skill1") # no change - - def test_save_m2m_graph_simple(self): - job = {"@graph": [ - {"title": "job test", "slug": "slugjob", - }, - ]} - - meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} - - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=job) - serializer.is_valid() - result = serializer.save() - - self.assertEquals(result.title, "job test") - self.assertIs(result.skills.count(), 0) - - def test_save_m2m_graph_with_nested(self): - skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="a") - skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="b") - - job = {"@graph": [ - {"title": "job test", - "slug": "slugj", - "skills": {"@id": "_.123"} - }, - {"@id": "_.123", "title": "skill3 NEW", "obligatoire": "obligatoire", "slug": "skill3"}, - ]} - - meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} - - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=job) - serializer.is_valid() - result = serializer.save() - - self.assertEquals(result.title, "job test") - self.assertIs(result.skills.count(), 1) - self.assertEquals(result.skills.all()[0].title, "skill3 NEW") # creation on the fly - - def test_save_without_nested_fields(self): - skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="a") - skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="b") - job = {"title": "job test", "slug": "c"} - - meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} - - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=job) - serializer.is_valid() - result = serializer.save() - - self.assertEquals(result.title, "job test") - self.assertIs(result.skills.count(), 0) - - def test_save_on_sub_iri(self): - """ - POST /job-offers/1/skills/ - """ - job = JobOffer.objects.create(title="job test") - skill = {"title": "new SKILL"} - - meta_args = {'model': Skill, 'depth': 2, 'fields': ("@id", "title")} - - meta_class = type('Meta', (), meta_args) - serializer_class = type(LDPSerializer)('SkillSerializer', (LDPSerializer,), {'Meta': meta_class}) - serializer = serializer_class(data=skill) - serializer.is_valid() - kwargs = {} - kwargs['joboffer'] = job - result = serializer.save(**kwargs) - - self.assertEquals(result.title, "new SKILL") - self.assertIs(result.joboffer_set.count(), 1) - self.assertEquals(result.joboffer_set.get(), job) - self.assertIs(result.joboffer_set.get().skills.count(), 1) - def test_save_fk_graph_with_nested(self): post = { '@graph': [ @@ -232,42 +62,6 @@ class Save(TestCase): self.assertEquals(response.data['title'], "title") self.assertEquals(response.data['invoice']['title'], "title 3") - #Â https://www.w3.org/TR/json-ld/#value-objects - def test_save_field_with_value_object(self): - post = { - 'http://happy-dev.fr/owl/#title': { - '@value': "title", - '@language': "en" - } - } - response = self.client.post('/invoices/', data=json.dumps(post), content_type='application/ld+json') - self.assertEqual(response.status_code, 201) - self.assertEquals(response.data['title'], "title") - - # from JSON-LD spec: "The value associated with the @value key MUST be either a string, a number, true, false or null" - def test_save_field_with_invalid_value_object(self): - invoice = Invoice.objects.create(title="title 3") - post = { - 'http://happy-dev.fr/owl/#invoice': { - '@value': {'title': 'title', '@id': "https://happy-dev.fr{}{}/".format(Model.container_id(invoice), invoice.id)} - } - } - response = self.client.post('/batchs/', data=json.dumps(post), content_type='application/ld+json') - self.assertEqual(response.status_code, 400) - - # TODO: bug with PyLD: https://github.com/digitalbazaar/pyld/issues/142 - # from JSON-LD spec: "If the value associated with the @type key is @json, the value MAY be either an array or an object" - '''def test_save_field_with_object_value_object(self): - invoice = Invoice.objects.create(title="title 3") - post = { - 'http://happy-dev.fr/owl/#invoice': { - '@value': {'title': 'title', '@id': "https://happy-dev.fr{}{}/".format(Model.container_id(invoice), invoice.id)}, - '@type': '@json' - } - } - response = self.client.post('/batchs/', data=json.dumps(post), content_type='application/ld+json') - self.assertEqual(response.status_code, 201)''' - def test_post_should_accept_missing_field_id_nullable(self): body = [ { @@ -455,69 +249,41 @@ class Save(TestCase): response = self.client.get('/projects/{}/'.format(project.pk)) self.assertEqual(response.data['team']['ldp:contains'][0]['@id'], "http://external.user/user/1/") - # unit tests for a specific bug: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/307 - def test_direct_boolean_field(self): - profile = UserProfile.objects.create(user=self.user) - setting = NotificationSetting.objects.create(user=profile, receiveMail=False) - body = { - 'http://happy-dev.fr/owl/#@id': setting.urlid, - 'receiveMail': True, - "@context": {"@vocab": "http://happy-dev.fr/owl/#", "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", - "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "ldp": "http://www.w3.org/ns/ldp#", - "foaf": "http://xmlns.com/foaf/0.1/", "name": "rdfs:label", - "acl": "http://www.w3.org/ns/auth/acl#", "permissions": "acl:accessControl", - "mode": "acl:mode", "geo": "http://www.w3.org/2003/01/geo/wgs84_pos#", "lat": "geo:lat", - "lng": "geo:long"} + # Â https://www.w3.org/TR/json-ld/#value-objects + def test_post_field_with_value_object(self): + post = { + 'http://happy-dev.fr/owl/#title': { + '@value': "title", + '@language': "en" + } } + response = self.client.post('/invoices/', data=json.dumps(post), content_type='application/ld+json') + self.assertEqual(response.status_code, 201) + self.assertEquals(response.data['title'], "title") - response = self.client.patch('/notificationsettings/{}/'.format(setting.pk), - data=json.dumps(body), - content_type='application/ld+json') - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['receiveMail'], True) - - def test_nested_container_boolean_field_no_slug(self): - profile = UserProfile.objects.create(user=self.user) - setting = NotificationSetting.objects.create(user=profile, receiveMail=False) - body = { - 'settings': { - 'http://happy-dev.fr/owl/#@id': setting.urlid, - 'receiveMail': True - }, - "@context": {"@vocab": "http://happy-dev.fr/owl/#", "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", - "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "ldp": "http://www.w3.org/ns/ldp#", - "foaf": "http://xmlns.com/foaf/0.1/", "name": "rdfs:label", - "acl": "http://www.w3.org/ns/auth/acl#", "permissions": "acl:accessControl", - "mode": "acl:mode", "geo": "http://www.w3.org/2003/01/geo/wgs84_pos#", "lat": "geo:lat", - "lng": "geo:long"} + # from JSON-LD spec: "The value associated with the @value key MUST be either a string, a number, true, false or null" + def test_save_field_with_invalid_value_object(self): + invoice = Invoice.objects.create(title="title 3") + post = { + 'http://happy-dev.fr/owl/#invoice': { + '@value': {'title': 'title', + '@id': "https://happy-dev.fr{}{}/".format(Model.container_id(invoice), invoice.id)} + } } + response = self.client.post('/batchs/', data=json.dumps(post), content_type='application/ld+json') + self.assertEqual(response.status_code, 400) - response = self.client.patch('/userprofiles/{}/'.format(profile.slug), - data=json.dumps(body), - content_type='application/ld+json') - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['settings']['receiveMail'], True) - - # variation where the lookup_field for NotificationSetting (pk) is provided - def test_nested_container_boolean_field_with_slug(self): - profile = UserProfile.objects.create(user=self.user) - setting = NotificationSetting.objects.create(user=profile, receiveMail=False) - body = { - 'settings': { - 'pk': setting.pk, - 'http://happy-dev.fr/owl/#@id': setting.urlid, - 'receiveMail': True - }, - "@context": {"@vocab": "http://happy-dev.fr/owl/#", "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", - "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "ldp": "http://www.w3.org/ns/ldp#", - "foaf": "http://xmlns.com/foaf/0.1/", "name": "rdfs:label", - "acl": "http://www.w3.org/ns/auth/acl#", "permissions": "acl:accessControl", - "mode": "acl:mode", "geo": "http://www.w3.org/2003/01/geo/wgs84_pos#", "lat": "geo:lat", - "lng": "geo:long"} + # TODO: bug with PyLD: https://github.com/digitalbazaar/pyld/issues/142 + # from JSON-LD spec: "If the value associated with the @type key is @json, the value MAY be either an array or an object" + ''' + def test_save_field_with_object_value_object(self): + invoice = Invoice.objects.create(title="title 3") + post = { + 'http://happy-dev.fr/owl/#invoice': { + '@value': {'title': 'title', '@id': "https://happy-dev.fr{}{}/".format(Model.container_id(invoice), invoice.id)}, + '@type': '@json' + } } - - response = self.client.patch('/userprofiles/{}/'.format(profile.slug), - data=json.dumps(body), - content_type='application/ld+json') - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['settings']['receiveMail'], True) + response = self.client.post('/batchs/', data=json.dumps(post), content_type='application/ld+json') + self.assertEqual(response.status_code, 201) + ''' \ No newline at end of file -- GitLab From 47e72c14a04ffe7682683982eb6c3c4a72d46c37 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 12 Jan 2021 12:54:39 +0000 Subject: [PATCH 06/31] syntax: added test for view-side of #333 --- djangoldp/tests/tests_update.py | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/djangoldp/tests/tests_update.py b/djangoldp/tests/tests_update.py index e265bd2f..de748ae0 100644 --- a/djangoldp/tests/tests_update.py +++ b/djangoldp/tests/tests_update.py @@ -545,3 +545,42 @@ class Update(TestCase): self.assertEquals(tasks[0].pk, pre_existing_task.pk) self.assertEquals(tasks[1].title, "tache 2") + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/333 + '''def test_update_container_nested_view(self): + circle = Circle.objects.create(name='test') + pre_existing = CircleMember.objects.create(user=self.user, circle=circle, is_admin=False) + another_user = get_user_model().objects.create_user(username='u2', email='u2@b.com', password='pw') + + body = { + "@id": "{}/circles/{}/".format(settings.BASE_URL, circle.pk), + "http://happy-dev.fr/owl/#name": "Updated Name", + "http://happy-dev.fr/owl/#members": { + "ldp:contains": [ + {"@id": "{}/circle-members/{}/".format(settings.BASE_URL, pre_existing.pk), + "http://happy-dev.fr/owl/#is_admin": True}, + {"http://happy-dev.fr/owl/#user": {"@id": another_user.urlid}, + "http://happy-dev.fr/owl/#is_admin": False}, + ] + } + } + + response = \ + self.client.put('/circles/{}/'.format(circle.pk), data=json.dumps(body), content_type='application/ld+json') + print(str(self.user.urlid)) + print(str(response.data)) + self.assertEqual(response.status_code, 200) + + self.assertEquals(response.data['name'], circle.name) + self.assertEqual(response.data['@id'], circle.urlid) + self.assertIs(CircleMember.objects.count(), 2) + self.assertIs(circle.members.count(), 2) + self.assertIs(circle.team.count(), 2) + + members = circle.members.all().order_by('pk') + self.assertEqual(members[0].user, self.user) + self.assertEqual(members[0].urlid, pre_existing.urlid) + self.assertEqual(members[0].pk, pre_existing.pk) + self.assertEqual(members[0].is_admin, True) + self.assertEqual(members[1].user, another_user) + self.assertEqual(members[1].is_admin, False)''' + -- GitLab From dc1239e7ed380bd2b59f9ecc145037d04106cb55 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 12 Jan 2021 12:55:19 +0000 Subject: [PATCH 07/31] bugfix: fixed typo in views.py --- djangoldp/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangoldp/views.py b/djangoldp/views.py index d35b7ede..c9ed24cd 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -559,7 +559,7 @@ class LDPViewSet(LDPViewSetGenerator): if self.model: queryset = self.model.objects.all() else: - queryset = super(LDPView, self).get_queryset(*args, **kwargs) + queryset = super(LDPViewSet, self).get_queryset(*args, **kwargs) if self.prefetch_fields is None: depth = getattr(self, 'depth', Model.get_meta(self.model, 'depth', 0)) self.prefetch_fields = get_prefetch_fields(self.model, self.get_serializer(), depth) -- GitLab From a89553fabdbd703619bb66b02498c4e8cd808d03 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 12 Jan 2021 15:14:33 +0000 Subject: [PATCH 08/31] update: tests_model_serializer.py --- djangoldp/tests/runner.py | 1 + djangoldp/tests/tests_model_serializer.py | 820 ++++++++++++++++++++++ 2 files changed, 821 insertions(+) create mode 100644 djangoldp/tests/tests_model_serializer.py diff --git a/djangoldp/tests/runner.py b/djangoldp/tests/runner.py index 26433496..5b41fc2b 100644 --- a/djangoldp/tests/runner.py +++ b/djangoldp/tests/runner.py @@ -19,6 +19,7 @@ test_runner = DiscoverRunner(verbosity=1) failures = test_runner.run_tests([ 'djangoldp.tests.tests_settings', 'djangoldp.tests.tests_ldp_model', + 'djangoldp.tests.tests_model_serializer', 'djangoldp.tests.tests_ldp_viewset', 'djangoldp.tests.tests_user_permissions', 'djangoldp.tests.tests_guardian', diff --git a/djangoldp/tests/tests_model_serializer.py b/djangoldp/tests/tests_model_serializer.py new file mode 100644 index 00000000..e5871d1c --- /dev/null +++ b/djangoldp/tests/tests_model_serializer.py @@ -0,0 +1,820 @@ +import uuid +from django.conf import settings +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework.test import APIRequestFactory, APIClient + +from djangoldp.serializers import LDPSerializer, LDListMixin +from djangoldp.tests.models import Post, UserProfile, Resource, Circle, CircleMember, Invoice, Batch, Task, ModelTask +from djangoldp.tests.models import Skill, JobOffer, Conversation, Message, Project + + +class LDPModelSerializerTestCase(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.client = APIClient() + self.user = get_user_model().objects.create_user(username='john', email='jlennon@beatles.com', + password='glass onion') + self.client.force_authenticate(user=self.user) + LDListMixin.to_representation_cache.reset() + LDPSerializer.to_representation_cache.reset() + + def _get_serializer_class(self, model, depth, fields): + meta_args = {'model': model, 'depth': depth, 'fields': fields} + + meta_class = type('Meta', (), meta_args) + return type(LDPSerializer)('TestSerializer', (LDPSerializer,), {'Meta': meta_class}) + + def test_update_container_new_resource_replace(self): + # 2 pre-existing skills, one will be replaced and the other updated + redundant_skill = Skill.objects.create(title="to drop", obligatoire="obligatoire", slug="slug1") + pre_existing_skill = Skill.objects.create(title="to keep", obligatoire="obligatoire", slug="slug2") + job = JobOffer.objects.create(title="job test") + job.skills.add(redundant_skill) + job.skills.add(pre_existing_skill) + + post = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job.slug), + "title": "job test updated", + "skills": { + "ldp:contains": [ + {"title": "new skill", "obligatoire": "okay"}, + {"@id": "{}/skills/{}/".format(settings.BASE_URL, pre_existing_skill.slug), "title": "z"}, + ]} + } + + serializer_class = self._get_serializer_class(JobOffer, 2, ("@id", "title", "skills")) + serializer = serializer_class(data=post, instance=job) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + self.assertEquals(result.title, "job test updated") + self.assertIs(result.skills.count(), 2) + skills = result.skills.all().order_by("title") + self.assertEquals(skills[0].title, "new skill") + self.assertEquals(skills[0].obligatoire, "okay") + self.assertEquals(skills[1].title, "z") # updated + self.assertEquals(skills[1].obligatoire, pre_existing_skill.obligatoire) + + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/326 + ''' + def test_update_container_edit_and_new_resource_append(self): + pre_existing_skill_a = Skill.objects.create(title="to keep", obligatoire="obligatoire", slug="slug1") + pre_existing_skill_b = Skill.objects.create(title="to keep", obligatoire="obligatoire", slug="slug2") + job = JobOffer.objects.create(title="job test") + job.skills.add(pre_existing_skill_a) + job.skills.add(pre_existing_skill_b) + + post = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job.slug), + "skills": { + "ldp:contains": [ + {"title": "new skill", "obligatoire": "okay"}, + {"@id": "{}/skills/{}/".format(settings.BASE_URL, pre_existing_skill_b.slug), "title": "z"}, + ]} + } + + serializer_class = self._get_serializer_class(JobOffer, 2, ("@id", "title", "skills")) + serializer = serializer_class(data=post, instance=job) + serializer.is_valid(raise_exception=True) + result = serializer.save(partial=True) + + self.assertEquals(result.title, job.title) + self.assertIs(result.skills.count(), 3) + skills = result.skills.all().order_by('title') + self.assertEquals(skills[0].title, "new skill") # new skill + self.assertEquals(skills[1].title, pre_existing_skill_a.title) # old skill unchanged + self.assertEquals(skills[2].title, "z") # updated + self.assertEquals(skills[2].obligatoire, pre_existing_skill_b.obligatoire) # another field not updated + ''' + + def test_update_container_edit_and_new_external_resources(self): + job = JobOffer.objects.create(title="job test") + pre_existing_external = Skill.objects.create(title="to keep", obligatoire="obligatoire", + urlid="https://external.com/skills/2/") + job.skills.add(pre_existing_external) + + post = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job.slug), + "skills": { + "ldp:contains": [ + {"@id": "https://external.com/skills/1/", "title": "external skill", "obligatoire": "okay"}, + {"@id": "https://external.com/skills/2/", "title": "to keep", "obligatoire": "okay"}, + ]} + } + + serializer_class = self._get_serializer_class(JobOffer, 2, ("@id", "title", "skills")) + serializer = serializer_class(data=post, instance=job) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + skills = result.skills.all().order_by('urlid') + self.assertEquals(result.title, job.title) + self.assertEqual(result.pk, job.pk) + self.assertEqual(result.urlid, job.urlid) + self.assertIs(result.skills.count(), 2) + self.assertEquals(skills[0].title, "external skill") # new skill + self.assertEquals(skills[0].urlid, "https://external.com/skills/1/") # new skill + self.assertEquals(skills[0].obligatoire, "okay") + self.assertEquals(skills[1].title, pre_existing_external.title) # old skill unchanged + self.assertEquals(skills[1].urlid, pre_existing_external.urlid) + self.assertEquals(skills[1].obligatoire, "okay") + self.assertEquals(skills[1].pk, pre_existing_external.pk) + + def test_update_container_attach_existing_resource(self): + job = JobOffer.objects.create(title="job test") + another_job = JobOffer.objects.create(title="job2") + pre_existing_skill = Skill.objects.create(title="to keep", obligatoire="obligatoire") + another_job.skills.add(pre_existing_skill) + + self.assertIs(job.skills.count(), 0) + + post = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job.slug), + "skills": { + "ldp:contains": [ + {"@id": "{}/skills/{}/".format(settings.BASE_URL, pre_existing_skill.slug)}, + ]} + } + + serializer_class = self._get_serializer_class(JobOffer, 2, ("@id", "title", "skills")) + serializer = serializer_class(data=post, instance=job) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + skills = result.skills.all().order_by('urlid') + self.assertEquals(result.title, job.title) + self.assertEqual(result.pk, job.pk) + self.assertEqual(result.urlid, job.urlid) + self.assertIs(result.skills.count(), 1) + self.assertEquals(skills[0].urlid, pre_existing_skill.urlid) + self.assertIs(another_job.skills.count(), 1) + self.assertIs(Skill.objects.count(), 1) + + def test_update_container_attach_existing_resource_external(self): + job = JobOffer.objects.create(title="job test") + another_job = JobOffer.objects.create(title="job2") + pre_existing_external = Skill.objects.create(title="to keep", obligatoire="obligatoire", + urlid="https://external.com/skills/2/") + another_job.skills.add(pre_existing_external) + + self.assertIs(job.skills.count(), 0) + + post = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job.slug), + "skills": { + "ldp:contains": [ + {"@id": pre_existing_external.urlid}, + ]} + } + + serializer_class = self._get_serializer_class(JobOffer, 2, ("@id", "title", "skills")) + serializer = serializer_class(data=post, instance=job) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + skills = result.skills.all().order_by('urlid') + self.assertEquals(result.title, job.title) + self.assertEqual(result.pk, job.pk) + self.assertEqual(result.urlid, job.urlid) + self.assertIs(result.skills.count(), 1) + self.assertEquals(skills[0].urlid, pre_existing_external.urlid) + self.assertIs(another_job.skills.count(), 1) + self.assertIs(Skill.objects.count(), 1) + + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/344 + def test_update_container_mismatched_type_urlid(self): + job = JobOffer.objects.create(title="job test") + another_job = JobOffer.objects.create(title="job2") + + # contains internal urlid which refers to a different type of object entirely, and one which refers to container + post = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job.slug), + "skills": { + "ldp:contains": [ + {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, another_job.slug)}, + ]} + } + + serializer_class = self._get_serializer_class(JobOffer, 2, ("@id", "title", "skills")) + serializer = serializer_class(data=post, instance=job) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/345 + ''' + def test_update_container_mismatched_type_urlid_2(self): + job = JobOffer.objects.create(title="job test") + + # contains internal urlid which refers to a container + post = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job.slug), + "skills": { + "ldp:contains": [ + {"@id": "{}/skills/".format(settings.BASE_URL)}, + ]} + } + + serializer_class = self._get_serializer_class(JobOffer, 2, ("@id", "title", "skills")) + serializer = serializer_class(data=post, instance=job) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + # TODO: assert correct error is thrown + ''' + + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/344 + def test_update_container_mismatched_type_urlid_external(self): + job = JobOffer.objects.create(title="job test") + + # contains external mismatched urlids which refers to a container + post = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job.slug), + "skills": { + "ldp:contains": [ + {"@id": "https://external.com/skills/"}, + ]} + } + + serializer_class = self._get_serializer_class(JobOffer, 2, ("@id", "title", "skills")) + serializer = serializer_class(data=post, instance=job) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/346 + '''def test_update_container_attach_nonexistent_local_resource(self): + job = JobOffer.objects.create(title="job test") + + self.assertEqual(JobOffer.objects.count(), 1) + self.assertEqual(job.skills.count(), 0) + self.assertEqual(Skill.objects.count(), 0) + + post = {"@id": "{}/job-offers/{}/".format(settings.BASE_URL, job.slug), + "skills": { + "ldp:contains": [ + {"@id": "{}/skills/404/".format(settings.BASE_URL)}, + ]} + } + + serializer_class = self._get_serializer_class(JobOffer, 2, ("@id", "title", "skills")) + serializer = serializer_class(data=post, instance=job) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + self.assertEqual(JobOffer.objects.count(), 1) + self.assertEqual(job.skills.count(), 0) + self.assertEqual(Skill.objects.count(), 0)''' + + # CircleMember is different to Skill because it represents a many-to-many relationship via a through model + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/333 + '''def test_update_m2m_relationship_with_through_model_add_and_edit(self): + circle = Circle.objects.create(name='test') + pre_existing = CircleMember.objects.create(user=self.user, circle=circle, is_admin=False) + another_user = get_user_model().objects.create_user(username='u2', email='u2@b.com', password='pw') + + post = { + "@id": "{}/circles/{}/".format(settings.BASE_URL, circle.pk), + "name": "Updated Name", + "members": { + "ldp:contains": [ + {"@id": "{}/circle-members/{}/".format(settings.BASE_URL, pre_existing.pk), "is_admin": True}, + {"user": {"@id": another_user.urlid }, "is_admin": False}, + ] + } + } + + serializer_class = self._get_serializer_class(Circle, 2, ("@id", "name", "description", "members", "team")) + serializer = serializer_class(data=post, instance=circle) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + self.assertEquals(result.name, circle.name) + self.assertEqual(result.pk, circle.pk) + self.assertEqual(result.urlid, circle.urlid) + self.assertIs(result.members.count(), 2) + self.assertIs(result.team.count(), 2) + + members = result.members.all().order_by('pk') + self.assertEqual(members[0].user, self.user) + self.assertEqual(members[0].urlid, pre_existing.urlid) + self.assertEqual(members[0].pk, pre_existing.pk) + self.assertEqual(members[0].is_admin, True) + self.assertEqual(members[1].user, another_user) + self.assertEqual(members[1].is_admin, False) + + # TODO: variation on the above using external resources + def test_update_m2m_relationship_with_through_model_add_and_edit_external_resources(self): + pass + + # NOTE: this test if failing due to missing the 'invoice_id' field (see #333) + # variation of this test exists in tests_update.py with different behaviour + def test_update_container_twice_nested(self): + invoice = Invoice.objects.create(title='test') + pre_existing_batch = Batch.objects.create(title='batch1', invoice=invoice) + pre_existing_task = ModelTask.objects.create(title='task1', batch=pre_existing_batch) + + post = { + "@id": "{}/invoices/{}/".format(settings.BASE_URL, invoice.pk), + "title": "new", + "batches": [ + { + "@id": "{}/batchs/{}/".format(settings.BASE_URL, pre_existing_batch.pk), + "title": "new", + "tasks": [ + { + "@id": "{}/modeltasks/{}/".format(settings.BASE_URL, pre_existing_task.pk), + "title": "new" + }, + { + "title": "tache 2" + } + ] + }, + { + "title": "z", + } + ] + } + + serializer_class = self._get_serializer_class(Invoice, 2, ("@id", "title", "batches")) + serializer = serializer_class(data=post, instance=invoice) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + self.assertEquals(result.title, "new") + self.assertEquals(result.urlid, invoice.urlid) + self.assertEquals(result.pk, invoice.pk) + + self.assertIs(result.batches.count(), 2) + batches = result.batches.all().order_by('title') + self.assertEquals(batches[0].title, "new") + self.assertEquals(batches[0].urlid, pre_existing_batch.urlid) + self.assertEquals(batches[1].title, "z") + + self.assertIs(batches[0].tasks.count(), 2) + tasks = batches[0].tasks.all().order_by('title') + self.assertEquals(tasks[0].title, "new") + self.assertEquals(tasks[0].urlid, pre_existing_task.urlid) + self.assertEquals(tasks[1].title, "tache 2") + + # variation on the above test with external resources + def test_update_container_twice_nested_external_resources(self): + invoice = Invoice.objects.create(urlid='https://external.com/invoices/1/', title='test') + pre_existing_batch = Batch.objects.create(urlid='https://external.com/batchs/1/', title='batch1', invoice=invoice) + pre_existing_task = ModelTask.objects.create(urlid='https://external.com/tasks/1/', title='task1', batch=pre_existing_batch) + + post = { + "@id": invoice.urlid, + "title": "new", + "batches": [ + { + "@id": pre_existing_batch.urlid, + "title": "new", + "tasks": [ + { + "@id": pre_existing_task.urlid, + "title": "new" + }, + { + "@id": "https://anotherexternal.com/tasks/1/", + "title": "tache 2" + } + ] + }, + { + "@id": "https://yetanotherexternal.com/batchs/1/", + "title": "z" + } + ] + } + + serializer_class = self._get_serializer_class(Invoice, 2, ("@id", "title", "batches")) + serializer = serializer_class(data=post, instance=invoice) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + self.assertEquals(result.title, "new") + self.assertEquals(result.urlid, invoice.urlid) + self.assertEquals(result.pk, invoice.pk) + + self.assertIs(result.batches.count(), 2) + batches = result.batches.all().order_by('title') + self.assertEquals(batches[0].title, "new") + self.assertEquals(batches[0].urlid, pre_existing_batch.urlid) + self.assertEquals(batches[1].title, "z") + + self.assertIs(batches[0].tasks.count(), 2) + tasks = batches[0].tasks.all().order_by('title') + self.assertEquals(tasks[0].title, "new") + self.assertEquals(tasks[0].urlid, pre_existing_task.urlid) + self.assertEquals(tasks[1].title, "tache 2")''' + + # variation on the test where a field is omitted on each level (no changes are made) + def test_update_container_twice_nested_no_changes_missing_fields(self): + invoice = Invoice.objects.create(title='test') + pre_existing_batch = Batch.objects.create(title='batch1', invoice=invoice) + pre_existing_task = ModelTask.objects.create(title='task1', batch=pre_existing_batch) + + post = { + "@id": "{}/invoices/{}/".format(settings.BASE_URL, invoice.pk), + "batches": [ + { + "@id": "{}/batchs/{}/".format(settings.BASE_URL, pre_existing_batch.pk), + "tasks": [ + { + "@id": "{}/tasks/{}/".format(settings.BASE_URL, pre_existing_task.pk), + } + ] + } + ] + } + + serializer_class = self._get_serializer_class(Invoice, 2, ("@id", "title", "batches")) + serializer = serializer_class(data=post, instance=invoice) + serializer.is_valid(raise_exception=True) + result = serializer.save(partial=True) + + self.assertEquals(result.title, invoice.title) + self.assertEquals(result.urlid, invoice.urlid) + self.assertEquals(result.pk, invoice.pk) + + self.assertIs(result.batches.count(), 1) + batches = result.batches.all() + self.assertEquals(batches[0].title, pre_existing_batch.title) + self.assertEquals(batches[0].urlid, pre_existing_batch.urlid) + + self.assertIs(batches[0].tasks.count(), 1) + tasks = batches[0].tasks.all() + self.assertEquals(tasks[0].title, pre_existing_task.title) + + def test_update_graph_edit_and_new_resource(self): + redundant_skill = Skill.objects.create(title="to drop", obligatoire="obligatoire", slug="slug1") + skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="slug2") + skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="slug3") + job1 = JobOffer.objects.create(title="job test", slug="slug4") + job1.skills.add(redundant_skill) + + job = {"@graph": + [ + { + "@id": "{}/job-offers/{}/".format(settings.BASE_URL, job1.slug), + "title": "job test updated", + "skills": { + "ldp:contains": [ + {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill1.slug)}, + {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill2.slug)}, + {"@id": "_.123"}, + ]} + }, + { + "@id": "_.123", + "title": "new skill", + "obligatoire": "okay" + }, + { + "@id": "{}/skills/{}/".format(settings.BASE_URL, skill1.slug), + }, + { + "@id": "{}/skills/{}/".format(settings.BASE_URL, skill2.slug), + "title": "skill2 UP" + } + ] + } + + serializer_class = self._get_serializer_class(JobOffer, 2, ("@id", "title", "skills")) + serializer = serializer_class(data=job, instance=job1) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + skills = result.skills.all().order_by('title') + + self.assertEquals(result.title, "job test updated") + self.assertIs(result.skills.count(), 3) + self.assertEquals(skills[0].title, "new skill") # new skill + self.assertEquals(skills[1].title, "skill1") # no change + self.assertEquals(skills[2].title, "skill2 UP") # title updated + + def test_update_graph_2(self): + skill = Skill.objects.create(title="to drop", obligatoire="obligatoire", slug="slug") + skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="slug1") + skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="slug2") + job1 = JobOffer.objects.create(title="job test", slug="slug1") + job1.skills.add(skill) + + job = {"@graph": + [ + { + "@id": "{}/job-offers/{}/".format(settings.BASE_URL, job1.slug), + "title": "job test updated", + "skills": { + "@id": "{}/job-offers/{}/skills/".format(settings.BASE_URL, job1.slug) + } + }, + { + "@id": "_.123", + "title": "new skill", + "obligatoire": "okay" + }, + { + "@id": "{}/skills/{}/".format(settings.BASE_URL, skill1.slug), + }, + { + "@id": "{}/skills/{}/".format(settings.BASE_URL, skill2.slug), + "title": "skill2 UP" + }, + { + '@id': "{}/job-offers/{}/skills/".format(settings.BASE_URL, job1.slug), + "ldp:contains": [ + {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill1.slug)}, + {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill2.slug)}, + {"@id": "_.123"}, + ] + } + ] + } + + meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} + + meta_class = type('Meta', (), meta_args) + serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) + serializer = serializer_class(data=job, instance=job1) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + skills = result.skills.all().order_by('title') + + self.assertEquals(result.title, "job test updated") + self.assertIs(result.skills.count(), 3) + self.assertEquals(skills[0].title, "new skill") # new skill + self.assertEquals(skills[1].title, "skill1") # no change + self.assertEquals(skills[2].title, "skill2 UP") # title updated + self.assertEquals(skill, skill._meta.model.objects.get(pk=skill.pk)) # title updated + + def test_update_list_with_reverse_relation(self): + user1 = get_user_model().objects.create() + conversation = Conversation.objects.create(description="Conversation 1", author_user=user1) + message1 = Message.objects.create(text="Message 1", conversation=conversation, author_user=user1) + message2 = Message.objects.create(text="Message 2", conversation=conversation, author_user=user1) + + json = {"@graph": [ + { + "@id": "{}/messages/{}/".format(settings.BASE_URL, message1.pk), + "text": "Message 1 UP" + }, + { + "@id": "{}/messages/{}/".format(settings.BASE_URL, message2.pk), + "text": "Message 2 UP" + }, + { + '@id': "{}/conversations/{}/".format(settings.BASE_URL, conversation.pk), + 'description': "Conversation 1 UP", + "message_set": [ + {"@id": "{}/messages/{}/".format(settings.BASE_URL, message1.pk)}, + {"@id": "{}/messages/{}/".format(settings.BASE_URL, message2.pk)}, + ] + } + ] + } + + meta_args = {'model': Conversation, 'depth': 2, 'fields': ("@id", "description", "message_set")} + + meta_class = type('Meta', (), meta_args) + serializer_class = type(LDPSerializer)('ConversationSerializer', (LDPSerializer,), {'Meta': meta_class}) + serializer = serializer_class(data=json, instance=conversation) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + messages = result.message_set.all().order_by('text') + + self.assertEquals(result.description, "Conversation 1 UP") + self.assertIs(result.message_set.count(), 2) + self.assertEquals(messages[0].text, "Message 1 UP") + self.assertEquals(messages[1].text, "Message 2 UP") + + def test_add_new_element_with_foreign_key_id(self): + user1 = get_user_model().objects.create() + conversation = Conversation.objects.create(description="Conversation 1", author_user=user1) + message1 = Message.objects.create(text="Message 1", conversation=conversation, author_user=user1) + message2 = Message.objects.create(text="Message 2", conversation=conversation, author_user=user1) + + json = {"@graph": [ + { + "@id": "{}/messages/{}/".format(settings.BASE_URL, message1.pk), + "text": "Message 1 UP", + "author_user": { + '@id': "{}/users/{}/".format(settings.BASE_URL, user1.pk) + } + }, + { + "@id": "{}/messages/{}/".format(settings.BASE_URL, message2.pk), + "text": "Message 2 UP", + "author_user": { + '@id': user1.urlid + } + }, + { + "@id": "_:b1", + "text": "Message 3 NEW", + "author_user": { + '@id': user1.urlid + } + }, + { + '@id': "{}/conversations/{}/".format(settings.BASE_URL, conversation.pk), + "author_user": { + '@id': user1.urlid + }, + 'description': "Conversation 1 UP", + 'message_set': { + "@id": "{}/conversations/{}/message_set/".format(settings.BASE_URL, conversation.pk) + } + }, + { + '@id': "{}/conversations/{}/message_set/".format(settings.BASE_URL, conversation.pk), + "ldp:contains": [ + {"@id": "{}/messages/{}/".format(settings.BASE_URL, message1.pk)}, + {"@id": "{}/messages/{}/".format(settings.BASE_URL, message2.pk)}, + {"@id": "_:b1"} + ] + } + ] + } + + meta_args = {'model': Conversation, 'depth': 2, 'fields': ("@id", "description", "message_set")} + + meta_class = type('Meta', (), meta_args) + serializer_class = type(LDPSerializer)('ConversationSerializer', (LDPSerializer,), {'Meta': meta_class}) + serializer = serializer_class(data=json, instance=conversation) + serializer.is_valid(raise_exception=True) + result = serializer.save() + + messages = result.message_set.all().order_by('text') + + self.assertEquals(result.description, "Conversation 1 UP") + self.assertIs(result.message_set.count(), 3) + self.assertEquals(messages[0].text, "Message 1 UP") + self.assertEquals(messages[1].text, "Message 2 UP") + self.assertEquals(messages[2].text, "Message 3 NEW") + + # TODO: variation on https://git.startinblox.com/djangoldp-packages/djangoldp/issues/344 + '''def test_update_container_invalid_fk_reference_given(self): + pass''' + + def test_save_m2m_graph_with_many_nested(self): + invoice = { + "@graph": [ + { + "@id": "./", + "batches": {"@id": "_:b381"}, + "title": "Nouvelle facture", + "date": "" + }, + { + "@id": "_:b381", + "tasks": {"@id": "_:b382"}, + "title": "Batch 1" + }, + { + "@id": "_:b382", + "title": "Tache 1" + } + ] + } + + meta_args = {'model': Invoice, 'depth': 2, 'fields': ("@id", "title", "batches", "date")} + + meta_class = type('Meta', (), meta_args) + serializer_class = type(LDPSerializer)('InvoiceSerializer', (LDPSerializer,), {'Meta': meta_class}) + serializer = serializer_class(data=invoice) + serializer.is_valid() + result = serializer.save() + + self.assertEquals(result.title, "Nouvelle facture") + self.assertIs(result.batches.count(), 1) + self.assertEquals(result.batches.all()[0].title, "Batch 1") + self.assertIs(result.batches.all()[0].tasks.count(), 1) + self.assertEquals(result.batches.all()[0].tasks.all()[0].title, "Tache 1") + + def test_save_m2m(self): + skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="slug1") + skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="slug2") + + job = {"title": "job test", + "slug": "slug1", + "skills": { + "ldp:contains": [ + {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill1.slug)}, + {"@id": "{}/skills/{}/".format(settings.BASE_URL, skill2.slug), "title": "skill2 UP"}, + {"title": "skill3", "obligatoire": "obligatoire", "slug": "slug3"}, + ]} + } + + meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} + + meta_class = type('Meta', (), meta_args) + serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) + serializer = serializer_class(data=job) + serializer.is_valid() + result = serializer.save() + + self.assertEquals(result.title, "job test") + self.assertIs(result.skills.count(), 3) + self.assertEquals(result.skills.all()[0].title, "skill1") # no change + self.assertEquals(result.skills.all()[1].title, "skill2 UP") # title updated + self.assertEquals(result.skills.all()[2].title, "skill3") # creation on the fly + + # variation switching the http prefix of the BASE_URL in the request + @override_settings(BASE_URL='http://happy-dev.fr/') + def test_save_m2m_switch_base_url_prefix(self): + skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="slug1") + + job = {"title": "job test", + "slug": "slug1", + "skills": { + "ldp:contains": [ + {"@id": "https://happy-dev.fr/skills/{}/".format(skill1.slug)}, + ]} + } + + meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} + + meta_class = type('Meta', (), meta_args) + serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) + serializer = serializer_class(data=job) + serializer.is_valid() + result = serializer.save() + + self.assertEquals(result.title, "job test") + self.assertIs(result.skills.count(), 1) + self.assertEquals(result.skills.all()[0].title, "skill1") # no change + + def test_save_m2m_graph_simple(self): + job = {"@graph": [ + {"title": "job test", "slug": "slugjob", + }, + ]} + + meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} + + meta_class = type('Meta', (), meta_args) + serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) + serializer = serializer_class(data=job) + serializer.is_valid() + result = serializer.save() + + self.assertEquals(result.title, "job test") + self.assertIs(result.skills.count(), 0) + + def test_save_m2m_graph_with_nested(self): + skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="a") + skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="b") + + job = {"@graph": [ + {"title": "job test", + "slug": "slugj", + "skills": {"@id": "_.123"} + }, + {"@id": "_.123", "title": "skill3 NEW", "obligatoire": "obligatoire", "slug": "skill3"}, + ]} + + meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} + + meta_class = type('Meta', (), meta_args) + serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) + serializer = serializer_class(data=job) + serializer.is_valid() + result = serializer.save() + + self.assertEquals(result.title, "job test") + self.assertIs(result.skills.count(), 1) + self.assertEquals(result.skills.all()[0].title, "skill3 NEW") # creation on the fly + + def test_save_without_nested_fields(self): + skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="a") + skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="b") + job = {"title": "job test", "slug": "c"} + + meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills", "slug")} + + meta_class = type('Meta', (), meta_args) + serializer_class = type(LDPSerializer)('JobOfferSerializer', (LDPSerializer,), {'Meta': meta_class}) + serializer = serializer_class(data=job) + serializer.is_valid() + result = serializer.save() + + self.assertEquals(result.title, "job test") + self.assertIs(result.skills.count(), 0) + + def test_save_on_sub_iri(self): + """ + POST /job-offers/1/skills/ + """ + job = JobOffer.objects.create(title="job test") + skill = {"title": "new SKILL"} + + meta_args = {'model': Skill, 'depth': 2, 'fields': ("@id", "title")} + + meta_class = type('Meta', (), meta_args) + serializer_class = type(LDPSerializer)('SkillSerializer', (LDPSerializer,), {'Meta': meta_class}) + serializer = serializer_class(data=skill) + serializer.is_valid() + kwargs = {} + kwargs['joboffer'] = job + result = serializer.save(**kwargs) + + self.assertEquals(result.title, "new SKILL") + self.assertIs(result.joboffer_set.count(), 1) + self.assertEquals(result.joboffer_set.get(), job) + self.assertIs(result.joboffer_set.get().skills.count(), 1) -- GitLab From d651af06452ebbaed1b2a3e4b1f5136952112381 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Mon, 18 Jan 2021 11:49:58 +0000 Subject: [PATCH 09/31] bugfix: has_model_permissions is checked by OwnerAuthAnonPermissions if user is anonymous --- djangoldp/permissions.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index f764cb41..29084b62 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -114,6 +114,13 @@ class OwnerAuthAnonPermissions(LDPBasePermission): perms = perms.union(set(authenticated_perms)) return perms + def has_permission(self, request, view): + """concerned with the permissions to access the _view_""" + if request.user.is_anonymous: + if not self.has_model_permission(request, view): + return False + return True + class LDPObjectLevelPermissions(LDPBasePermission): def get_object_permissions(self, request, view, obj): -- GitLab From 3e940a7cc5d8e4c83b3531493748d9c8559d4c9b Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Mon, 18 Jan 2021 14:35:23 +0000 Subject: [PATCH 10/31] bugfix: fix failing tests --- djangoldp/tests/tests_anonymous_permissions.py | 4 ++-- djangoldp/tests/tests_guardian.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/djangoldp/tests/tests_anonymous_permissions.py b/djangoldp/tests/tests_anonymous_permissions.py index 76854d20..8fff2f19 100644 --- a/djangoldp/tests/tests_anonymous_permissions.py +++ b/djangoldp/tests/tests_anonymous_permissions.py @@ -30,9 +30,9 @@ class TestAnonymousUserPermissions(TestCase): body = {'title':"job_updated"} response = self.client.put('/job-offers/{}/'.format(self.job.pk), data=json.dumps(body), content_type='application/ld+json') - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, 403) def test_patch_request_for_anonymousUser(self): response = self.client.patch('/job-offers/' + str(self.job.pk) + "/", content_type='application/ld+json') - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, 403) diff --git a/djangoldp/tests/tests_guardian.py b/djangoldp/tests/tests_guardian.py index fda42658..79712376 100644 --- a/djangoldp/tests/tests_guardian.py +++ b/djangoldp/tests/tests_guardian.py @@ -57,9 +57,8 @@ class TestsGuardian(APITestCase): def test_get_dummy_anonymous_user(self): self.setUpGuardianDummyWithPerms() response = self.client.get('/permissionless-dummys/') - # I have no object permissions - I should receive a 200 with an empty list - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['ldp:contains']), 0) + # I have no object permissions - I should receive a 403 + self.assertEqual(response.status_code, 403) def test_list_dummy_exception(self): self.setUpLoggedInUser() -- GitLab From 169375023a052a9e62639a58b6ef616da4c24ac4 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 19 Jan 2021 17:57:36 +0000 Subject: [PATCH 11/31] syntax: renamed OwnerAuthAnonPermissions to ModelConfiguredPermissions --- .gitignore | 2 ++ djangoldp/filters.py | 4 ++-- djangoldp/permissions.py | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 4450e25a..cde3d5b2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ build *~ *.swp djangoldp/tests/tests_temp.py +*/.idea/* +.DS_STORE diff --git a/djangoldp/filters.py b/djangoldp/filters.py index 0eb46c9b..4bb9d630 100644 --- a/djangoldp/filters.py +++ b/djangoldp/filters.py @@ -12,7 +12,7 @@ class LDPPermissionsFilterBackend(ObjectPermissionsFilter): """ def filter_queryset(self, request, queryset, view): from djangoldp.models import Model - from djangoldp.permissions import LDPPermissions, OwnerAuthAnonPermissions + from djangoldp.permissions import LDPPermissions, ModelConfiguredPermissions # compares the requirement for GET, with what the user has on the MODEL ldp_permissions = LDPPermissions() @@ -26,7 +26,7 @@ class LDPPermissionsFilterBackend(ObjectPermissionsFilter): # those objects I have by grace of being owner if Model.get_meta(view.model, 'owner_field', None) is not None: - perms_class = OwnerAuthAnonPermissions() + perms_class = ModelConfiguredPermissions() owner_perms = perms_class.get_permission_settings(view.model)[2] if 'view' in owner_perms: owned_objects = [q.pk for q in queryset if Model.is_owner(view.model, request.user, q)] diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 29084b62..1f336af9 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -54,6 +54,7 @@ class LDPBasePermission(DjangoObjectPermissions): """ concerned with the permissions to access the _model_ in most situations you won't need to override this. It is primarily called by has_object_permission + checked when POSTing to LDPViewSet """ required_perms = self.get_required_permissions(request.method, view.model) return self.compare_permissions(required_perms, self.get_model_permissions(request, view)) @@ -71,7 +72,7 @@ class LDPBasePermission(DjangoObjectPermissions): return True -class OwnerAuthAnonPermissions(LDPBasePermission): +class ModelConfiguredPermissions(LDPBasePermission): # *DEFAULT* model-level permissions for anon, auth and owner statuses anonymous_perms = ['view'] authenticated_perms = ['inherit'] @@ -138,5 +139,5 @@ class LDPObjectLevelPermissions(LDPBasePermission): return perms -class LDPPermissions(LDPObjectLevelPermissions, OwnerAuthAnonPermissions): +class LDPPermissions(LDPObjectLevelPermissions, ModelConfiguredPermissions): filter_backends = [LDPPermissionsFilterBackend] -- GitLab From ddebc6b6c442662a6fed8093aa4fd9556e0d5a2b Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 19 Jan 2021 18:15:34 +0000 Subject: [PATCH 12/31] feature: default superuser perms --- djangoldp/models.py | 4 +- djangoldp/permissions.py | 13 ++++- djangoldp/tests/tests_user_permissions.py | 60 +++++++++++++++++++++-- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/djangoldp/models.py b/djangoldp/models.py index 48d6c17d..9328190e 100644 --- a/djangoldp/models.py +++ b/djangoldp/models.py @@ -19,7 +19,7 @@ from django.utils.decorators import classonlymethod from rest_framework.utils import model_meta from djangoldp.fields import LDPUrlField -from djangoldp.permissions import LDPPermissions +from djangoldp.permissions import LDPPermissions, DEFAULT_DJANGOLDP_PERMISSIONS logger = logging.getLogger('djangoldp') @@ -157,7 +157,7 @@ class Model(models.Model): return path class Meta: - default_permissions = ('add', 'change', 'delete', 'view', 'control') + default_permissions = DEFAULT_DJANGOLDP_PERMISSIONS abstract = True depth = 0 diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 1f336af9..b97f594a 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -5,6 +5,9 @@ from rest_framework.permissions import DjangoObjectPermissions from djangoldp.filters import LDPPermissionsFilterBackend +DEFAULT_DJANGOLDP_PERMISSIONS = ['add', 'change', 'delete', 'view', 'control'] + + class LDPBasePermission(DjangoObjectPermissions): """ A base class from which all permission classes should inherit. @@ -77,6 +80,8 @@ class ModelConfiguredPermissions(LDPBasePermission): anonymous_perms = ['view'] authenticated_perms = ['inherit'] owner_perms = ['inherit'] + # superuser has all permissions by default + superuser_perms = DEFAULT_DJANGOLDP_PERMISSIONS def _get_permissions_setting(self, model, setting, parent_perms=None): '''Auxiliary function returns the configured permissions given to parameterised setting, or default''' @@ -95,15 +100,16 @@ class ModelConfiguredPermissions(LDPBasePermission): anonymous_perms = self._get_permissions_setting(model, 'anonymous_perms') authenticated_perms = self._get_permissions_setting(model, 'authenticated_perms', anonymous_perms) owner_perms = self._get_permissions_setting(model, 'owner_perms', authenticated_perms) + superuser_perms = self._get_permissions_setting(model, 'superuser_perms', owner_perms) - return anonymous_perms, authenticated_perms, owner_perms + return anonymous_perms, authenticated_perms, owner_perms, superuser_perms def get_model_permissions(self, request, view, obj=None): '''analyses the Model's set anonymous, authenticated and owner_permissions and returns these''' from djangoldp.models import Model model = view.model - anonymous_perms, authenticated_perms, owner_perms = self.get_permission_settings(model) + anonymous_perms, authenticated_perms, owner_perms, superuser_perms = self.get_permission_settings(model) perms = super().get_model_permissions(request, view, obj) if request.user.is_anonymous: @@ -113,6 +119,9 @@ class ModelConfiguredPermissions(LDPBasePermission): perms = perms.union(set(owner_perms)) else: perms = perms.union(set(authenticated_perms)) + + if request.user.is_superuser: + perms = perms.union(set(superuser_perms)) return perms def has_permission(self, request, view): diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py index 032e2b11..c7c6cbfd 100644 --- a/djangoldp/tests/tests_user_permissions.py +++ b/djangoldp/tests/tests_user_permissions.py @@ -256,11 +256,65 @@ class TestUserPermissions(APITestCase): response = self.client.delete('/ownedresources/{}/'.format(their_resource.pk)) self.assertEqual(response.status_code, 404) + def _make_self_superuser(self): + self.user.is_superuser = True + self.user.save() + + # test superuser permissions (configured on model) + def test_list_superuser_perms(self): + another_user = get_user_model().objects.create_user(username='test', email='test@test.com', password='test') + their_resource = OwnedResource.objects.create(description='another test', user=another_user) + + response = self.client.get('/ownedresources/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 0) + + # now I'm superuser, I have the permissions + self._make_self_superuser() + + response = self.client.get('/ownedresources/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 1) + + def test_get_superuser_perms(self): + another_user = get_user_model().objects.create_user(username='test', email='test@test.com', password='test') + their_resource = OwnedResource.objects.create(description='another test', user=another_user) + + response = self.client.patch('/ownedresources/{}/'.format(their_resource.pk)) + self.assertEqual(response.status_code, 404) + + self._make_self_superuser() + + response = self.client.patch('/ownedresources/{}/'.format(their_resource.pk)) + self.assertEqual(response.status_code, 200) + + def test_put_superuser_perms(self): + another_user = get_user_model().objects.create_user(username='test', email='test@test.com', password='test') + their_profile = UserProfile.objects.create(user=another_user, slug=another_user.username, description='about') + + response = self.client.patch('/userprofiles/{}/'.format(their_profile.slug)) + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/336 + self.assertEqual(response.status_code, 404) + + self._make_self_superuser() + + response = self.client.patch('/userprofiles/{}/'.format(their_profile.slug)) + self.assertEqual(response.status_code, 200) + + def test_delete_superuser_perms(self): + another_user = get_user_model().objects.create_user(username='test', email='test@test.com', password='test') + their_resource = OwnedResource.objects.create(description='another test', user=another_user) + + response = self.client.delete('/ownedresources/{}/'.format(their_resource.pk)) + self.assertEqual(response.status_code, 404) + + self._make_self_superuser() + + response = self.client.delete('/ownedresources/{}/'.format(their_resource.pk)) + self.assertEqual(response.status_code, 204) + # TODO: I have model (or object?) permissions. Attempt to make myself owner and thus upgrade my permissions # TODO: I have owner permissions. Attempt to make myself the owner of another resource by changing the FK ref # TODO: repeat of the above but upgrading another users' permissions # TODO: test models with custom permissions classes active (test that it overrides default behaviour) - - # TODO: test superuser permissions - # https://git.startinblox.com/djangoldp-packages/djangoldp/issues/295 -- GitLab From e5b5b77432123f9b1b3f751b34d96f891efd6044 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 19 Jan 2021 18:30:08 +0000 Subject: [PATCH 13/31] feature: DEFAULT_SUPERUSER_PERMS in settings --- djangoldp/permissions.py | 3 +-- djangoldp/tests/tests_user_permissions.py | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index b97f594a..8a0e7a73 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -1,4 +1,3 @@ -import time from django.conf import settings from django.contrib.auth.models import _user_get_all_permissions from rest_framework.permissions import DjangoObjectPermissions @@ -81,7 +80,7 @@ class ModelConfiguredPermissions(LDPBasePermission): authenticated_perms = ['inherit'] owner_perms = ['inherit'] # superuser has all permissions by default - superuser_perms = DEFAULT_DJANGOLDP_PERMISSIONS + superuser_perms = getattr(settings, 'DEFAULT_SUPERUSER_PERMS', DEFAULT_DJANGOLDP_PERMISSIONS) def _get_permissions_setting(self, model, setting, parent_perms=None): '''Auxiliary function returns the configured permissions given to parameterised setting, or default''' diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py index c7c6cbfd..82dd35dc 100644 --- a/djangoldp/tests/tests_user_permissions.py +++ b/djangoldp/tests/tests_user_permissions.py @@ -24,6 +24,10 @@ class TestUserPermissions(APITestCase): self.group.permissions.add(view_perm) self.group.save() + def _make_self_superuser(self): + self.user.is_superuser = True + self.user.save() + # list - simple def test_get_for_authenticated_user(self): response = self.client.get('/job-offers/') @@ -256,10 +260,6 @@ class TestUserPermissions(APITestCase): response = self.client.delete('/ownedresources/{}/'.format(their_resource.pk)) self.assertEqual(response.status_code, 404) - def _make_self_superuser(self): - self.user.is_superuser = True - self.user.save() - # test superuser permissions (configured on model) def test_list_superuser_perms(self): another_user = get_user_model().objects.create_user(username='test', email='test@test.com', password='test') -- GitLab From f9890336c4a5fe8ac0c46a1418fb86658eaf179b Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 19 Jan 2021 18:37:34 +0000 Subject: [PATCH 14/31] syntax: docs for superuser_perms --- docs/create_model.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/create_model.md b/docs/create_model.md index 1895d4b5..d3206e2a 100644 --- a/docs/create_model.md +++ b/docs/create_model.md @@ -297,7 +297,7 @@ This allows you to add permissions for anonymous, logged in user, author ... in By default `LDPPermissions` is used. Specific permissin classes can be developed to fit special needs. -## anonymous_perms, user_perms, owner_perms +## anonymous_perms, user_perms, owner_perms, superuser_perms Those allow you to set permissions from your model's meta. @@ -326,8 +326,9 @@ class Todo(Model): class Meta: anonymous_perms = ['view'] - authenticated_perms = ['inherit', 'add'] - owner_perms = ['inherit', 'change', 'control', 'delete'] + authenticated_perms = ['inherit', 'add'] # inherits from anonymous + owner_perms = ['inherit', 'change', 'control', 'delete'] # inherits from authenticated + superuser_perms = ['inherit'] # inherits from owner owner_field = 'user' ``` @@ -335,6 +336,10 @@ class Todo(Model): Important note: If you need to give permissions to owner's object, don't forget to add auto_author in model's meta +Superuser's are by default configured to have all of the default DjangoLDP permissions +* you can restrict their permissions globally by setting `DEFAULT_SUPERUSER_PERMS = []` in your server settings +* you can change it on a per-model basis as described here. Please note that if you use a custom permissions class you will need to give superusers this permission explicitly, or use the `SuperUsersPermission` class on the model which will grant superusers all permissions + ### view_set In case of custom viewset, you can use -- GitLab From cbc842605fde3b0885636e71f35b98f7a3769d44 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 19 Jan 2021 18:44:03 +0000 Subject: [PATCH 15/31] feature: SuperUserPermission class --- djangoldp/permissions.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 8a0e7a73..a29976f7 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -147,5 +147,22 @@ class LDPObjectLevelPermissions(LDPBasePermission): return perms +class SuperUserPermission(LDPBasePermission): + def has_permission(self, request, view): + if request.user.is_superuser: + return True + return super().has_permission(request, view) + + def has_model_permission(self, request, view): + if request.user.is_superuser: + return True + return super().has_model_permission(request, view) + + def has_object_permission(self, request, view, obj): + if request.user.is_superuser: + return True + return super().has_object_permission(request, view, obj) + + class LDPPermissions(LDPObjectLevelPermissions, ModelConfiguredPermissions): filter_backends = [LDPPermissionsFilterBackend] -- GitLab From 9c69c522a7adc271ffce787af5d48c1ed9363a68 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 19 Jan 2021 19:22:44 +0000 Subject: [PATCH 16/31] bugfix: fix #343 --- djangoldp/__init__.py | 3 ++- djangoldp/filters.py | 14 ++++++++++++-- djangoldp/tests/models.py | 17 ++++++++++++++++- djangoldp/tests/tests_user_permissions.py | 22 +++++++++++++++++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/djangoldp/__init__.py b/djangoldp/__init__.py index cffed812..960dbcfa 100644 --- a/djangoldp/__init__.py +++ b/djangoldp/__init__.py @@ -5,5 +5,6 @@ __version__ = '0.0.0' options.DEFAULT_NAMES += ( 'lookup_field', 'rdf_type', 'rdf_context', 'auto_author', 'auto_author_field', 'owner_field', 'view_set', 'container_path', 'permission_classes', 'serializer_fields', 'serializer_fields_exclude', 'empty_containers', - 'nested_fields', 'nested_fields_exclude', 'depth', 'anonymous_perms', 'authenticated_perms', 'owner_perms') + 'nested_fields', 'nested_fields_exclude', 'depth', 'anonymous_perms', 'authenticated_perms', 'owner_perms', + 'superuser_perms') default_app_config = 'djangoldp.apps.DjangoldpConfig' diff --git a/djangoldp/filters.py b/djangoldp/filters.py index 4bb9d630..9f64b365 100644 --- a/djangoldp/filters.py +++ b/djangoldp/filters.py @@ -10,6 +10,12 @@ class LDPPermissionsFilterBackend(ObjectPermissionsFilter): Default FilterBackend for LDPPermissions. If user does not have model-level permissions, filters by Django-Guardian's get_objects_for_user """ + + shortcut_kwargs = { + 'accept_global_perms': False, + 'with_superuser': True + } + def filter_queryset(self, request, queryset, view): from djangoldp.models import Model from djangoldp.permissions import LDPPermissions, ModelConfiguredPermissions @@ -21,13 +27,17 @@ class LDPPermissionsFilterBackend(ObjectPermissionsFilter): if not request.user.is_anonymous or ( getattr(settings, 'ANONYMOUS_USER_NAME', True) is not None and request.user != get_anonymous_user()): + # those objects I have by grace of group or object + # first figure out if the superuser has special permissions (important to the implementation in superclass) + perms_class = ModelConfiguredPermissions() + anon_perms, auth_perms, owner_perms, superuser_perms = perms_class.get_permission_settings(view.model) + self.shortcut_kwargs['with_superuser'] = 'view' in superuser_perms + object_perms = super().filter_queryset(request, queryset, view) # those objects I have by grace of being owner if Model.get_meta(view.model, 'owner_field', None) is not None: - perms_class = ModelConfiguredPermissions() - owner_perms = perms_class.get_permission_settings(view.model)[2] if 'view' in owner_perms: owned_objects = [q.pk for q in queryset if Model.is_owner(view.model, request.user, q)] return object_perms | queryset.filter(pk__in=owned_objects) diff --git a/djangoldp/tests/models.py b/djangoldp/tests/models.py index 5a1446da..afc18563 100644 --- a/djangoldp/tests/models.py +++ b/djangoldp/tests/models.py @@ -4,7 +4,7 @@ from django.db import models from django.utils.datetime_safe import date from djangoldp.models import Model -from djangoldp.permissions import LDPPermissions +from djangoldp.permissions import LDPPermissions, SuperUserPermission class User(AbstractUser, Model): @@ -283,3 +283,18 @@ class MyAbstractModel(Model): permission_classes = [LDPPermissions] abstract = True rdf_type = "wow:defaultrdftype" + + +class NoSuperUsersAllowedModel(Model): + class Meta(Model.Meta): + anonymous_perms = [] + authenticated_perms = [] + owner_perms = [] + superuser_perms = [] + permission_classes = [LDPPermissions] + + +class ComplexPermissionClassesModel(Model): + class Meta(Model.Meta): + permission_classes = [LDPPermissions, SuperUserPermission] + superuser_perms = [] diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py index 82dd35dc..e47d542c 100644 --- a/djangoldp/tests/tests_user_permissions.py +++ b/djangoldp/tests/tests_user_permissions.py @@ -3,7 +3,8 @@ from django.contrib.auth.models import Permission, Group from django.conf import settings from djangoldp.serializers import LDListMixin, LDPSerializer from rest_framework.test import APIClient, APITestCase -from djangoldp.tests.models import JobOffer, LDPDummy, PermissionlessDummy, Skill, UserProfile, OwnedResource +from djangoldp.tests.models import JobOffer, LDPDummy, PermissionlessDummy, Skill, UserProfile, OwnedResource, \ + NoSuperUsersAllowedModel, ComplexPermissionClassesModel import json @@ -313,6 +314,25 @@ class TestUserPermissions(APITestCase): response = self.client.delete('/ownedresources/{}/'.format(their_resource.pk)) self.assertEqual(response.status_code, 204) + # test where superuser_perms are configured on the model to be different + def test_superuser_perms_configured(self): + self._make_self_superuser() + + NoSuperUsersAllowedModel.objects.create() + self.assertEqual(NoSuperUsersAllowedModel.objects.count(), 1) + + response = self.client.get('/nosuperusersallowedmodels/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 0) + + # test list where SuperUserPermission is being used on a model in conjunction with LDPPermissions + def test_filter_backend_multiple_permission_classes_configured(self): + ComplexPermissionClassesModel.objects.create() + + response = self.client.get('/complexpermissionclassesmodels/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 1) + # TODO: I have model (or object?) permissions. Attempt to make myself owner and thus upgrade my permissions # TODO: I have owner permissions. Attempt to make myself the owner of another resource by changing the FK ref # TODO: repeat of the above but upgrading another users' permissions -- GitLab From 5f43f6369c810b346e8174ca6f50958cf921685d Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 19 Jan 2021 20:30:40 +0000 Subject: [PATCH 17/31] syntax: added test for #356 --- djangoldp/tests/models.py | 14 ++++++++++++ djangoldp/tests/tests_user_permissions.py | 26 +++++++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/djangoldp/tests/models.py b/djangoldp/tests/models.py index afc18563..bada7baf 100644 --- a/djangoldp/tests/models.py +++ b/djangoldp/tests/models.py @@ -100,6 +100,20 @@ class OwnedResource(Model): depth = 1 +class OwnedResourceVariant(Model): + description = models.CharField(max_length=255, blank=True, null=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name="owned_variant_resources", + on_delete=models.CASCADE) + + class Meta(Model.Meta): + anonymous_perms = [] + authenticated_perms = ['view', 'change'] + owner_perms = ['view', 'delete', 'add', 'change', 'control'] + owner_field = 'user' + serializer_fields = ['@id', 'description', 'user'] + depth = 1 + + class UserProfile(Model): description = models.CharField(max_length=255, blank=True, null=True) user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='userprofile', on_delete=models.CASCADE) diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py index e47d542c..192e136c 100644 --- a/djangoldp/tests/tests_user_permissions.py +++ b/djangoldp/tests/tests_user_permissions.py @@ -3,8 +3,8 @@ from django.contrib.auth.models import Permission, Group from django.conf import settings from djangoldp.serializers import LDListMixin, LDPSerializer from rest_framework.test import APIClient, APITestCase -from djangoldp.tests.models import JobOffer, LDPDummy, PermissionlessDummy, Skill, UserProfile, OwnedResource, \ - NoSuperUsersAllowedModel, ComplexPermissionClassesModel +from djangoldp.tests.models import JobOffer, LDPDummy, PermissionlessDummy, UserProfile, OwnedResource, \ + NoSuperUsersAllowedModel, ComplexPermissionClassesModel, OwnedResourceVariant import json @@ -333,8 +333,22 @@ class TestUserPermissions(APITestCase): self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data['ldp:contains']), 1) - # TODO: I have model (or object?) permissions. Attempt to make myself owner and thus upgrade my permissions - # TODO: I have owner permissions. Attempt to make myself the owner of another resource by changing the FK ref - # TODO: repeat of the above but upgrading another users' permissions + # I have model (or object?) permissions. Attempt to make myself owner and thus upgrade my permissions + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/356/ + ''' + def test_hack_model_perms_privilege_escalation(self): + another_user = get_user_model().objects.create_user(username='test', email='test@test.com', password='test') + resource = OwnedResourceVariant.objects.create(description='another test', user=another_user) - # TODO: test models with custom permissions classes active (test that it overrides default behaviour) + # authenticated has 'change' permission but only owner's have 'control' permission, meaning that I should + # not be able to change my privilege level + body = { + 'http://happy-dev.fr/owl/#user': {'@id': self.user.urlid} + } + response = self.client.put('/ownedresourcevariants/{}/'.format(resource.pk), data=json.dumps(body), + content_type='application/ld+json') + self.assertEqual(response.status_code, 200) + + resource = OwnedResourceVariant.objects.get(pk=resource.pk) + self.assertNotEqual(resource.user, self.user) + ''' -- GitLab From 54059c2eb9d038488db582916cfd258b908c48f5 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 26 Jan 2021 14:58:43 +0000 Subject: [PATCH 18/31] bugfix: fixed issue with permissions serialization --- djangoldp/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangoldp/models.py b/djangoldp/models.py index 9328190e..412227d3 100644 --- a/djangoldp/models.py +++ b/djangoldp/models.py @@ -312,7 +312,7 @@ class Model(models.Model): perms = Model.get_model_permissions(model_class, request, view, obj) if obj is not None: perms = perms.union(Model.get_object_permissions(model_class, request, view, obj)) - return perms + return [{'mode': {'@type': name.split('_')[0]}} for name in perms] @classmethod def is_owner(cls, model_class, user, obj): -- GitLab From 44da09dc19cec4dfdff046fa55ada599825afb61 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 26 Jan 2021 16:15:11 +0000 Subject: [PATCH 19/31] syntax: moving solution to serializer code --- djangoldp/models.py | 2 +- djangoldp/serializers.py | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/djangoldp/models.py b/djangoldp/models.py index 412227d3..9328190e 100644 --- a/djangoldp/models.py +++ b/djangoldp/models.py @@ -312,7 +312,7 @@ class Model(models.Model): perms = Model.get_model_permissions(model_class, request, view, obj) if obj is not None: perms = perms.union(Model.get_object_permissions(model_class, request, view, obj)) - return [{'mode': {'@type': name.split('_')[0]}} for name in perms] + return perms @classmethod def is_owner(cls, model_class, user, obj): diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index e4689829..889b6d11 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -60,6 +60,11 @@ class InMemoryCache: self.cache[cache_key].pop(vary, None) +def _seriailize_permissions(permissions): + '''takes a set or list of permissions and returns them in the JSON-LD format''' + return [{'mode': {'@type': name.split('_')[0]}} for name in permissions] + + def _ldp_container_representation(id, container_permissions=None, value=None): '''Utility function builds a LDP-format dictionary for passed container data''' represented_object = {'@id': id} @@ -141,7 +146,8 @@ class LDListMixin: if not isinstance(value, Iterable) and not isinstance(value, QuerySet): check_cache() - container_permissions = list(Model.get_model_permissions(child_model, self.context['request'], self.context['view'])) + container_permissions = _seriailize_permissions( + Model.get_model_permissions(child_model, self.context['request'], self.context['view'])) else: try: @@ -157,7 +163,7 @@ class LDListMixin: model=child_model) container_permissions = Model.get_model_permissions(child_model, self.context['request'], self.context['view']) - container_permissions = list(container_permissions.union( + container_permissions = _seriailize_permissions(container_permissions.union( Model.get_model_permissions(parent_model, self.context['request'], self.context['view']))) self.to_representation_cache.set(self.id, cache_vary, @@ -385,7 +391,8 @@ class LDPSerializer(HyperlinkedModelSerializer): model_class = obj.get_model_class() else: model_class = type(obj) - data['permissions'] = list(Model.get_permissions(model_class, self.context['request'], self.context['view'], obj)) + data['permissions'] = _seriailize_permissions( + Model.get_permissions(model_class, self.context['request'], self.context['view'], obj)) return data @@ -415,9 +422,9 @@ class LDPSerializer(HyperlinkedModelSerializer): if isinstance(instance, QuerySet): data = list(instance) id = '{}{}{}/'.format(settings.SITE_URL, '{}{}/', self.source) - permissions = list(Model.get_permissions(self.parent.Meta.model, - self.parent.context['request'], - self.parent.context['view'])) + permissions = _seriailize_permissions(Model.get_permissions(self.parent.Meta.model, + self.parent.context['request'], + self.parent.context['view'])) data = [serializer.to_representation(item) if item is not None else None for item in data] return _ldp_container_representation(id, container_permissions=permissions, value=data) else: -- GitLab From a0e919591ac5a7df4caed8a89115d26814128650 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Wed, 27 Jan 2021 07:35:47 +0000 Subject: [PATCH 20/31] bugfix: fix for custom permissions --- djangoldp/serializers.py | 2 +- djangoldp/tests/tests_guardian.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index 889b6d11..45ec1614 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -62,7 +62,7 @@ class InMemoryCache: def _seriailize_permissions(permissions): '''takes a set or list of permissions and returns them in the JSON-LD format''' - return [{'mode': {'@type': name.split('_')[0]}} for name in permissions] + return [{'mode': {'@type': name}} for name in permissions] def _ldp_container_representation(id, container_permissions=None, value=None): diff --git a/djangoldp/tests/tests_guardian.py b/djangoldp/tests/tests_guardian.py index 79712376..027423ef 100644 --- a/djangoldp/tests/tests_guardian.py +++ b/djangoldp/tests/tests_guardian.py @@ -46,6 +46,10 @@ class TestsGuardian(APITestCase): def setUpGuardianDummyWithPerms(self, perms=None, parent=None, group=False): self.dummy = self._get_dummy_with_perms(perms, parent, group) + # auxiliary function converts permission format for test + def _unpack_permissions(self, perms_from_response): + return [p['mode']['@type'] for p in perms_from_response] + # test that dummy with no permissions set returns no results def test_get_dummy_no_permissions(self): self.setUpLoggedInUser() @@ -147,7 +151,7 @@ class TestsGuardian(APITestCase): self.setUpGuardianDummyWithPerms(['custom_permission', 'view']) response = self.client.get('/permissionless-dummys/{}/'.format(self.dummy.slug)) - self.assertIn('custom_permission', response.data['permissions']) + self.assertIn('custom_permission', self._unpack_permissions(response.data['permissions'])) # test that duplicate permissions aren't returned def test_no_duplicate_permissions(self): @@ -159,8 +163,9 @@ class TestsGuardian(APITestCase): response = self.client.get('/dummys/{}/'.format(dummy.slug)) self.assertEqual(response.status_code, 200) - self.assertIn('view', response.data['permissions']) - view_perms = [perm for perm in response.data['permissions'] if perm == 'view'] + perms = self._unpack_permissions(response.data['permissions']) + self.assertIn('view', perms) + view_perms = [perm for perm in perms if perm == 'view'] self.assertEqual(len(view_perms), 1) # TODO: attempting to migrate my object permissions by changing FK reference -- GitLab From 25a81cc65c4e9cee388001ee77b5778e26a30e9b Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Thu, 28 Jan 2021 18:01:19 +0000 Subject: [PATCH 21/31] feature: utility functions to shorthand guardian checks --- djangoldp/utils.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 djangoldp/utils.py diff --git a/djangoldp/utils.py b/djangoldp/utils.py new file mode 100644 index 00000000..83075fd8 --- /dev/null +++ b/djangoldp/utils.py @@ -0,0 +1,14 @@ +from django.conf import settings +from guardian.utils import get_anonymous_user + + +# convenience function returns True if user is anonymous +def is_anonymous_user(user): + return user.is_anonymous or (getattr(settings, 'ANONYMOUS_USER_NAME', True) is not None and + user == get_anonymous_user()) + + +# convenience function returns True if user is authenticated +def is_authenticated_user(user): + return user.is_authenticated and (getattr(settings, 'ANONYMOUS_USER_NAME', True) is None or + user != get_anonymous_user()) -- GitLab From 1a76ca6b933d9b9b6fb3a471cc089d4b0cc576b5 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Fri, 29 Jan 2021 16:02:56 +0000 Subject: [PATCH 22/31] update: changed naming of get_model_permissions to get_container_permissions --- djangoldp/filters.py | 3 +-- djangoldp/models.py | 8 ++++---- djangoldp/permissions.py | 18 +++++++++--------- djangoldp/serializers.py | 6 +++--- djangoldp/views.py | 2 +- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/djangoldp/filters.py b/djangoldp/filters.py index 9f64b365..d728b5be 100644 --- a/djangoldp/filters.py +++ b/djangoldp/filters.py @@ -1,6 +1,5 @@ from django.conf import settings from guardian.utils import get_anonymous_user -from guardian.shortcuts import get_group_obj_perms_model from rest_framework.filters import BaseFilterBackend from rest_framework_guardian.filters import ObjectPermissionsFilter @@ -22,7 +21,7 @@ class LDPPermissionsFilterBackend(ObjectPermissionsFilter): # compares the requirement for GET, with what the user has on the MODEL ldp_permissions = LDPPermissions() - if ldp_permissions.has_model_permission(request, view): + if ldp_permissions.has_container_permission(request, view): return queryset if not request.user.is_anonymous or ( getattr(settings, 'ANONYMOUS_USER_NAME', True) is not None and diff --git a/djangoldp/models.py b/djangoldp/models.py index 9328190e..39df689d 100644 --- a/djangoldp/models.py +++ b/djangoldp/models.py @@ -287,14 +287,14 @@ class Model(models.Model): return cls @classonlymethod - def get_model_permissions(cls, model_class, request, view, obj=None): + def get_container_permissions(cls, model_class, request, view, obj=None): '''outputs the permissions given by all permissions_classes on the model_class on the model-level''' perms = set() view = copy.copy(view) view.model = model_class for permission_class in Model.get_permission_classes(model_class, [LDPPermissions]): - if hasattr(permission_class, 'get_model_permissions'): - perms = perms.union(permission_class().get_model_permissions(request, view, obj)) + if hasattr(permission_class, 'get_container_permissions'): + perms = perms.union(permission_class().get_container_permissions(request, view, obj)) return perms @classonlymethod @@ -309,7 +309,7 @@ class Model(models.Model): @classonlymethod def get_permissions(cls, model_class, request, view, obj=None): '''outputs the permissions given by all permissions_classes on the model_class on both the model and the object level''' - perms = Model.get_model_permissions(model_class, request, view, obj) + perms = Model.get_container_permissions(model_class, request, view, obj) if obj is not None: perms = perms.union(Model.get_object_permissions(model_class, request, view, obj)) return perms diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index a29976f7..faa583cf 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -27,7 +27,7 @@ class LDPBasePermission(DjangoObjectPermissions): 'DELETE': ['%(app_label)s.delete_%(model_name)s'], } - def get_model_permissions(self, request, view, obj=None): + def get_container_permissions(self, request, view, obj=None): """ outputs a set of permissions of a given model (container). Used in the generation of WebACLs in LDPSerializer rarely need to override this function @@ -43,7 +43,7 @@ class LDPBasePermission(DjangoObjectPermissions): def get_user_permissions(self, request, view, obj=None): '''returns a set of all model permissions and object permissions for given parameters''' - perms = self.get_model_permissions(request, view, obj) + perms = self.get_container_permissions(request, view, obj) if obj is not None: return perms.union(self.get_object_permissions(request, view, obj)) return perms @@ -52,14 +52,14 @@ class LDPBasePermission(DjangoObjectPermissions): """concerned with the permissions to access the _view_""" return True - def has_model_permission(self, request, view): + def has_container_permission(self, request, view): """ concerned with the permissions to access the _model_ in most situations you won't need to override this. It is primarily called by has_object_permission checked when POSTing to LDPViewSet """ required_perms = self.get_required_permissions(request.method, view.model) - return self.compare_permissions(required_perms, self.get_model_permissions(request, view)) + return self.compare_permissions(required_perms, self.get_container_permissions(request, view)) def has_object_permission(self, request, view, obj): """concerned with the permissions to access the _object_""" @@ -103,14 +103,14 @@ class ModelConfiguredPermissions(LDPBasePermission): return anonymous_perms, authenticated_perms, owner_perms, superuser_perms - def get_model_permissions(self, request, view, obj=None): + def get_container_permissions(self, request, view, obj=None): '''analyses the Model's set anonymous, authenticated and owner_permissions and returns these''' from djangoldp.models import Model model = view.model anonymous_perms, authenticated_perms, owner_perms, superuser_perms = self.get_permission_settings(model) - perms = super().get_model_permissions(request, view, obj) + perms = super().get_container_permissions(request, view, obj) if request.user.is_anonymous: perms = perms.union(set(anonymous_perms)) else: @@ -126,7 +126,7 @@ class ModelConfiguredPermissions(LDPBasePermission): def has_permission(self, request, view): """concerned with the permissions to access the _view_""" if request.user.is_anonymous: - if not self.has_model_permission(request, view): + if not self.has_container_permission(request, view): return False return True @@ -153,10 +153,10 @@ class SuperUserPermission(LDPBasePermission): return True return super().has_permission(request, view) - def has_model_permission(self, request, view): + def has_container_permission(self, request, view): if request.user.is_superuser: return True - return super().has_model_permission(request, view) + return super().has_container_permission(request, view) def has_object_permission(self, request, view, obj): if request.user.is_superuser: diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index 45ec1614..66efc628 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -147,7 +147,7 @@ class LDListMixin: if not isinstance(value, Iterable) and not isinstance(value, QuerySet): check_cache() container_permissions = _seriailize_permissions( - Model.get_model_permissions(child_model, self.context['request'], self.context['view'])) + Model.get_container_permissions(child_model, self.context['request'], self.context['view'])) else: try: @@ -162,9 +162,9 @@ class LDListMixin: value = child_model.get_queryset(self.context['request'], self.context['view'], queryset=value, model=child_model) - container_permissions = Model.get_model_permissions(child_model, self.context['request'], self.context['view']) + container_permissions = Model.get_container_permissions(child_model, self.context['request'], self.context['view']) container_permissions = _seriailize_permissions(container_permissions.union( - Model.get_model_permissions(parent_model, self.context['request'], self.context['view']))) + Model.get_container_permissions(parent_model, self.context['request'], self.context['view']))) self.to_representation_cache.set(self.id, cache_vary, _ldp_container_representation(self.id, diff --git a/djangoldp/views.py b/djangoldp/views.py index 423c4d9e..3317043b 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -517,7 +517,7 @@ class LDPViewSet(LDPViewSetGenerator): Raises an appropriate exception if the request is not permitted. """ for permission in self.get_permissions(): - if hasattr(permission, 'has_model_permission') and not permission.has_model_permission(request, self): + if hasattr(permission, 'has_container_permission') and not permission.has_container_permission(request, self): self.permission_denied( request, message=getattr(permission, 'message', None) -- GitLab From f6b3821a6c1f8855b3a290aa33f24f03c0b9fe4b Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Fri, 29 Jan 2021 17:03:38 +0000 Subject: [PATCH 23/31] syntax: obj always set in get_user_permissions --- djangoldp/permissions.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index faa583cf..9b4e7ddc 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -29,20 +29,21 @@ class LDPBasePermission(DjangoObjectPermissions): def get_container_permissions(self, request, view, obj=None): """ - outputs a set of permissions of a given model (container). Used in the generation of WebACLs in LDPSerializer - rarely need to override this function + outputs a set of permissions of a given container. Used in the generation of WebACLs in LDPSerializer """ return set() def get_object_permissions(self, request, view, obj): """ outputs the permissions of a given object instance. Used in the generation of WebACLs in LDPSerializer - rarely need to override this function """ return set() - def get_user_permissions(self, request, view, obj=None): - '''returns a set of all model permissions and object permissions for given parameters''' + def get_user_permissions(self, request, view, obj): + ''' + returns a set of all model permissions and object permissions for given parameters + You shouldn't override this function + ''' perms = self.get_container_permissions(request, view, obj) if obj is not None: return perms.union(self.get_object_permissions(request, view, obj)) -- GitLab From 3c2c14a0b10fbb7e398e436fa374bf58b1f7dc2a Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Fri, 29 Jan 2021 17:34:37 +0000 Subject: [PATCH 24/31] bugfix: SuperUserPermission extends get container/object permissions functions --- djangoldp/permissions.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 9b4e7ddc..2bf02fb6 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -149,6 +149,12 @@ class LDPObjectLevelPermissions(LDPBasePermission): class SuperUserPermission(LDPBasePermission): + def get_container_permissions(self, request, view, obj=None): + return set(DEFAULT_DJANGOLDP_PERMISSIONS) + + def get_object_permissions(self, request, view, obj): + return set(DEFAULT_DJANGOLDP_PERMISSIONS) + def has_permission(self, request, view): if request.user.is_superuser: return True -- GitLab From 780d53b8f1b82a243be04813c659be93e4312762 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Fri, 29 Jan 2021 17:41:35 +0000 Subject: [PATCH 25/31] bugfix: fix on previous commit --- djangoldp/permissions.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 2bf02fb6..240475ba 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -149,12 +149,18 @@ class LDPObjectLevelPermissions(LDPBasePermission): class SuperUserPermission(LDPBasePermission): + filter_backends = [] + def get_container_permissions(self, request, view, obj=None): - return set(DEFAULT_DJANGOLDP_PERMISSIONS) + if request.user.is_superuser: + return set(DEFAULT_DJANGOLDP_PERMISSIONS) + return super().get_container_permissions(request, view, obj) def get_object_permissions(self, request, view, obj): - return set(DEFAULT_DJANGOLDP_PERMISSIONS) - + if request.user.is_superuser: + return set(DEFAULT_DJANGOLDP_PERMISSIONS) + return super().get_object_permissions(request, view, obj) + def has_permission(self, request, view): if request.user.is_superuser: return True -- GitLab From 4d60020e5dd8be6d07046a2a3daf2c118fb8a7d5 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Fri, 29 Jan 2021 18:08:17 +0000 Subject: [PATCH 26/31] bugfix: safe use of Guardian AnonymousUser --- djangoldp/filters.py | 7 ++----- djangoldp/permissions.py | 9 +++++---- djangoldp/views.py | 3 ++- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/djangoldp/filters.py b/djangoldp/filters.py index d728b5be..8e1b441f 100644 --- a/djangoldp/filters.py +++ b/djangoldp/filters.py @@ -1,7 +1,6 @@ -from django.conf import settings -from guardian.utils import get_anonymous_user from rest_framework.filters import BaseFilterBackend from rest_framework_guardian.filters import ObjectPermissionsFilter +from djangoldp.utils import is_anonymous_user class LDPPermissionsFilterBackend(ObjectPermissionsFilter): @@ -23,10 +22,8 @@ class LDPPermissionsFilterBackend(ObjectPermissionsFilter): ldp_permissions = LDPPermissions() if ldp_permissions.has_container_permission(request, view): return queryset - if not request.user.is_anonymous or ( - getattr(settings, 'ANONYMOUS_USER_NAME', True) is not None and - request.user != get_anonymous_user()): + if not is_anonymous_user(request.user): # those objects I have by grace of group or object # first figure out if the superuser has special permissions (important to the implementation in superclass) perms_class = ModelConfiguredPermissions() diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 240475ba..7bc4bfe4 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -1,6 +1,7 @@ from django.conf import settings from django.contrib.auth.models import _user_get_all_permissions from rest_framework.permissions import DjangoObjectPermissions +from djangoldp.utils import is_anonymous_user from djangoldp.filters import LDPPermissionsFilterBackend @@ -112,7 +113,7 @@ class ModelConfiguredPermissions(LDPBasePermission): anonymous_perms, authenticated_perms, owner_perms, superuser_perms = self.get_permission_settings(model) perms = super().get_container_permissions(request, view, obj) - if request.user.is_anonymous: + if is_anonymous_user(request.user): perms = perms.union(set(anonymous_perms)) else: if obj is not None and Model.is_owner(view.model, request.user, obj): @@ -126,7 +127,7 @@ class ModelConfiguredPermissions(LDPBasePermission): def has_permission(self, request, view): """concerned with the permissions to access the _view_""" - if request.user.is_anonymous: + if is_anonymous_user(request.user): if not self.has_container_permission(request, view): return False return True @@ -141,7 +142,7 @@ class LDPObjectLevelPermissions(LDPBasePermission): perms = super().get_object_permissions(request, view, obj) - if obj is not None and not request.user.is_anonymous: + if obj is not None and not is_anonymous_user(request.user): forbidden_string = "_" + model_name return perms.union(set([p.replace(forbidden_string, '') for p in _user_get_all_permissions(request.user, obj)])) @@ -150,7 +151,7 @@ class LDPObjectLevelPermissions(LDPBasePermission): class SuperUserPermission(LDPBasePermission): filter_backends = [] - + def get_container_permissions(self, request, view, obj=None): if request.user.is_superuser: return set(DEFAULT_DJANGOLDP_PERMISSIONS) diff --git a/djangoldp/views.py b/djangoldp/views.py index 3317043b..af690fd8 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -29,6 +29,7 @@ from djangoldp.models import LDPSource, Model, Follower from djangoldp.permissions import LDPPermissions from djangoldp.filters import LocalObjectOnContainerPathBackend from djangoldp.related import get_prefetch_fields +from djangoldp.utils import is_authenticated_user from djangoldp.activities import ActivityQueueService, as_activitystream from djangoldp.activities import ActivityPubService from djangoldp.activities.errors import ActivityStreamDecodeError, ActivityStreamValidationError @@ -614,7 +615,7 @@ class LDPViewSet(LDPViewSetGenerator): else: pass response["Accept-Post"] = "application/ld+json" - if request.user.is_authenticated: + if is_authenticated_user(request.user): try: response['User'] = request.user.webid() except AttributeError: -- GitLab From 37fc263ebe92d9e2b16f166057134fa0034f2522 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Thu, 11 Feb 2021 11:48:16 +0000 Subject: [PATCH 27/31] bugfix: LDPPermissions hack around superuser grants from Django and Guardian --- djangoldp/permissions.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 7bc4bfe4..347326a0 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -1,5 +1,6 @@ from django.conf import settings from django.contrib.auth.models import _user_get_all_permissions +from django.contrib.auth import _get_backends from rest_framework.permissions import DjangoObjectPermissions from djangoldp.utils import is_anonymous_user from djangoldp.filters import LDPPermissionsFilterBackend @@ -134,6 +135,9 @@ class ModelConfiguredPermissions(LDPBasePermission): class LDPObjectLevelPermissions(LDPBasePermission): + def get_all_user_object_permissions(self, user, obj): + return _user_get_all_permissions(user, obj) + def get_object_permissions(self, request, view, obj): '''overridden to append permissions from all backends given to the user (e.g. Groups and object-level perms)''' from djangoldp.models import Model @@ -144,7 +148,8 @@ class LDPObjectLevelPermissions(LDPBasePermission): if obj is not None and not is_anonymous_user(request.user): forbidden_string = "_" + model_name - return perms.union(set([p.replace(forbidden_string, '') for p in _user_get_all_permissions(request.user, obj)])) + return perms.union(set([p.replace(forbidden_string, '') for p in + self.get_all_user_object_permissions(request.user, obj)])) return perms @@ -180,3 +185,10 @@ class SuperUserPermission(LDPBasePermission): class LDPPermissions(LDPObjectLevelPermissions, ModelConfiguredPermissions): filter_backends = [LDPPermissionsFilterBackend] + + def get_all_user_object_permissions(self, user, obj): + # if the super_user perms are no different from authenticated_perms, then we want to skip Django's auth backend + if user.is_superuser: + user.is_superuser = False + + return super().get_all_user_object_permissions(user, obj) -- GitLab From c97461e63dc0de698694af1b15b977b7a1833b12 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Mon, 15 Feb 2021 10:58:30 +0000 Subject: [PATCH 28/31] bugfix: restoring super after Hubl hack --- djangoldp/permissions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 347326a0..2abdd769 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -188,7 +188,12 @@ class LDPPermissions(LDPObjectLevelPermissions, ModelConfiguredPermissions): def get_all_user_object_permissions(self, user, obj): # if the super_user perms are no different from authenticated_perms, then we want to skip Django's auth backend + restore_super = False if user.is_superuser: user.is_superuser = False + restore_super = True - return super().get_all_user_object_permissions(user, obj) + perms = super().get_all_user_object_permissions(user, obj) + + user.is_superuser = restore_super + return perms -- GitLab From 6e49e27bdb7520db604e22610347d5146a596c00 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Wed, 17 Feb 2021 14:52:34 +0000 Subject: [PATCH 29/31] migrations for default permissions on Model Meta --- .../migrations/0015_auto_20210125_1847.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 djangoldp/migrations/0015_auto_20210125_1847.py diff --git a/djangoldp/migrations/0015_auto_20210125_1847.py b/djangoldp/migrations/0015_auto_20210125_1847.py new file mode 100644 index 00000000..a4050aea --- /dev/null +++ b/djangoldp/migrations/0015_auto_20210125_1847.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.17 on 2021-01-25 18:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangoldp', '0014_auto_20200909_2206'), + ] + + operations = [ + migrations.AlterModelOptions( + name='activity', + options={'default_permissions': ['add', 'change', 'delete', 'view', 'control']}, + ), + migrations.AlterModelOptions( + name='follower', + options={'default_permissions': ['add', 'change', 'delete', 'view', 'control']}, + ), + migrations.AlterModelOptions( + name='ldpsource', + options={'default_permissions': ['add', 'change', 'delete', 'view', 'control'], 'ordering': ('federation',)}, + ), + migrations.AlterModelOptions( + name='scheduledactivity', + options={'default_permissions': ['add', 'change', 'delete', 'view', 'control']}, + ), + ] -- GitLab From cefc645727a1a622937bd719425b4c6b4395b60c Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Wed, 17 Feb 2021 14:53:21 +0000 Subject: [PATCH 30/31] feature: settings for ignoring certain permissions values during serialization --- djangoldp/serializers.py | 33 ++++++++++++++++++----- djangoldp/tests/tests_user_permissions.py | 11 ++++++++ docs/create_model.md | 6 +++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index 66efc628..c1014e1e 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -29,6 +29,12 @@ from djangoldp.models import Model from djangoldp.permissions import LDPPermissions +# defaults for various DjangoLDP settings (see documentation) +SERIALIZE_EXCLUDE_PERMISSIONS_DEFAULT = ['inherit'] +SERIALIZE_EXCLUDE_CONTAINER_PERMISSIONS_DEFAULT = ['delete'] +SERIALIZE_EXCLUDE_OBJECT_PERMISSIONS_DEFAULT = [] + + class InMemoryCache: def __init__(self): @@ -60,9 +66,24 @@ class InMemoryCache: self.cache[cache_key].pop(vary, None) -def _seriailize_permissions(permissions): +def _serialize_permissions(permissions, exclude_perms): '''takes a set or list of permissions and returns them in the JSON-LD format''' - return [{'mode': {'@type': name}} for name in permissions] + exclude_perms = set(exclude_perms).union( + getattr(settings, 'SERIALIZE_EXCLUDE_PERMISSIONS', SERIALIZE_EXCLUDE_PERMISSIONS_DEFAULT)) + + return [{'mode': {'@type': name}} for name in permissions if name not in exclude_perms] + + +def _serialize_container_permissions(permissions): + exclude = getattr(settings, 'SERIALIZE_EXCLUDE_CONTAINER_PERMISSIONS', + SERIALIZE_EXCLUDE_CONTAINER_PERMISSIONS_DEFAULT) + return _serialize_permissions(permissions, exclude) + + +def _serialize_object_permissions(permissions): + exclude = getattr(settings, 'SERIALIZE_EXCLUDE_OBJECT_PERMISSIONS', + SERIALIZE_EXCLUDE_OBJECT_PERMISSIONS_DEFAULT) + return _serialize_permissions(permissions, exclude) def _ldp_container_representation(id, container_permissions=None, value=None): @@ -146,7 +167,7 @@ class LDListMixin: if not isinstance(value, Iterable) and not isinstance(value, QuerySet): check_cache() - container_permissions = _seriailize_permissions( + container_permissions = _serialize_container_permissions( Model.get_container_permissions(child_model, self.context['request'], self.context['view'])) else: @@ -163,7 +184,7 @@ class LDListMixin: model=child_model) container_permissions = Model.get_container_permissions(child_model, self.context['request'], self.context['view']) - container_permissions = _seriailize_permissions(container_permissions.union( + container_permissions = _serialize_container_permissions(container_permissions.union( Model.get_container_permissions(parent_model, self.context['request'], self.context['view']))) self.to_representation_cache.set(self.id, cache_vary, @@ -391,7 +412,7 @@ class LDPSerializer(HyperlinkedModelSerializer): model_class = obj.get_model_class() else: model_class = type(obj) - data['permissions'] = _seriailize_permissions( + data['permissions'] = _serialize_object_permissions( Model.get_permissions(model_class, self.context['request'], self.context['view'], obj)) return data @@ -422,7 +443,7 @@ class LDPSerializer(HyperlinkedModelSerializer): if isinstance(instance, QuerySet): data = list(instance) id = '{}{}{}/'.format(settings.SITE_URL, '{}{}/', self.source) - permissions = _seriailize_permissions(Model.get_permissions(self.parent.Meta.model, + permissions = _serialize_container_permissions(Model.get_permissions(self.parent.Meta.model, self.parent.context['request'], self.parent.context['view'])) data = [serializer.to_representation(item) if item is not None else None for item in data] diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py index 192e136c..9ab7ec55 100644 --- a/djangoldp/tests/tests_user_permissions.py +++ b/djangoldp/tests/tests_user_permissions.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission, Group from django.conf import settings +from django.test import override_settings from djangoldp.serializers import LDListMixin, LDPSerializer from rest_framework.test import APIClient, APITestCase from djangoldp.tests.models import JobOffer, LDPDummy, PermissionlessDummy, UserProfile, OwnedResource, \ @@ -30,9 +31,15 @@ class TestUserPermissions(APITestCase): self.user.save() # list - simple + @override_settings(SERIALIZE_EXCLUDE_PERMISSIONS=['inherit'], + SERIALIZE_CONTAINER_EXCLUDE_PERMISSIONS=['inherit', 'delete']) def test_get_for_authenticated_user(self): response = self.client.get('/job-offers/') self.assertEqual(response.status_code, 200) + # test serialized permissions + self.assertIn({'mode': {'@type': 'view'}}, response.data['permissions']) + self.assertNotIn({'mode': {'@type': 'inherit'}}, response.data['permissions']) + # self.assertNotIn({'mode': {'@type': 'delete'}}, response.data['permissions']) # TODO: list - I do not have permission from the model, but I do have permission via a Group I am assigned # https://git.startinblox.com/djangoldp-packages/djangoldp/issues/291 @@ -112,9 +119,12 @@ class TestUserPermissions(APITestCase): # TODO: test for DELETE scenario ''' + @override_settings(SERIALIZE_OBJECT_EXCLUDE_PERMISSIONS=['inherit']) def test_get_1_for_authenticated_user(self): response = self.client.get('/job-offers/{}/'.format(self.job.slug)) self.assertEqual(response.status_code, 200) + self.assertIn({'mode': {'@type': 'view'}}, response.data['permissions']) + self.assertNotIn({'mode': {'@type': 'inherit'}}, response.data['permissions']) def test_post_request_for_authenticated_user(self): post = {'http://happy-dev.fr/owl/#title': "job_created", "http://happy-dev.fr/owl/#slug": 'slug2'} @@ -232,6 +242,7 @@ class TestUserPermissions(APITestCase): response = self.client.get('/ownedresources/{}/'.format(my_resource.pk)) self.assertEqual(response.status_code, 200) self.assertEqual(response.data['@id'], my_resource.urlid) + self.assertIn({'mode': {'@type': 'delete'}}, response.data['permissions']) # I have permission to view this resource response = self.client.patch('/ownedresources/{}/'.format(their_resource.pk)) diff --git a/docs/create_model.md b/docs/create_model.md index d3206e2a..a169780e 100644 --- a/docs/create_model.md +++ b/docs/create_model.md @@ -291,6 +291,12 @@ Now when an instance of `MyModel` is saved, its `author_user` property will be s Django-Guardian is used by default to support object-level permissions. Custom permissions can be added to your model using this attribute. See the [Django-Guardian documentation](https://django-guardian.readthedocs.io/en/stable/userguide/assign.html) for more information +### Serializing Permissions + +* `SERIALIZE_EXCLUDE_PERMISSIONS`. Permissions which should always be excluded from serialization defaults to `['inherit']` +* `SERIALIZE_EXCLUDE_CONTAINER_PERMISSIONS_DEFAULT`. Excluded also when serializing containers `['delete']` +* `SERIALIZE_EXCLUDE_OBJECT_PERMISSIONS_DEFAULT`. Excluded also when serializing objects `[]` + ## permissions_classes This allows you to add permissions for anonymous, logged in user, author ... in the url: -- GitLab From db19a528fd54a3785567d331976d4f9c04ac6ee2 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Wed, 17 Feb 2021 14:59:59 +0000 Subject: [PATCH 31/31] bugfix: fix test_get_container --- djangoldp/tests/tests_get.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangoldp/tests/tests_get.py b/djangoldp/tests/tests_get.py index 8bbda0d8..7f705e0f 100644 --- a/djangoldp/tests/tests_get.py +++ b/djangoldp/tests/tests_get.py @@ -47,7 +47,7 @@ class TestGET(APITestCase): self.assertEquals(1, len(response.data['ldp:contains'])) self.assertIn('@type', response.data) self.assertIn('@type', response.data['ldp:contains'][0]) - self.assertEquals(5, len(response.data['permissions'])) # configured anonymous permissions to give all + self.assertEquals(4, len(response.data['permissions'])) # configured anonymous permissions to give all Invoice.objects.create(title="content") response = self.client.get('/invoices/', content_type='application/ld+json') -- GitLab