From df709ee36b000ce5e1281dd836199c5ce72bbd32 Mon Sep 17 00:00:00 2001
From: Calum Mackervoy <c.mackervoy@gmail.com>
Date: Wed, 26 Feb 2020 08:21:38 +0000
Subject: [PATCH] Django guardian support

---
 README.md                                     | 35 +++++++
 djangoldp/admin.py                            |  4 +-
 .../migrations/0004_auto_20200221_1118.py     | 19 ++++
 .../migrations/0005_auto_20200221_1127.py     | 19 ++++
 djangoldp/models.py                           | 13 ++-
 djangoldp/permissions.py                      | 41 +++++++--
 djangoldp/tests/djangoldp_urls.py             | 12 ++-
 djangoldp/tests/models.py                     | 43 ++++++---
 djangoldp/tests/runner.py                     |  2 +-
 djangoldp/tests/tests_guardian.py             | 92 +++++++++++++++++++
 djangoldp/tests/tests_update.py               | 16 ++--
 djangoldp/tests/tests_user_permissions.py     |  7 +-
 djangoldp/urls.py                             |  5 +
 13 files changed, 261 insertions(+), 47 deletions(-)
 create mode 100644 djangoldp/migrations/0004_auto_20200221_1118.py
 create mode 100644 djangoldp/migrations/0005_auto_20200221_1127.py
 create mode 100644 djangoldp/tests/tests_guardian.py

diff --git a/README.md b/README.md
index 404d022e..60727dd8 100644
--- a/README.md
+++ b/README.md
@@ -146,6 +146,28 @@ To start the server, `cd` to the root of your Django project and run :
 $ python3 manage.py runserver
 ```
 
+## Using DjangoLDP
+
+### Models
+
+To use DjangoLDP in your models you just need to extend djangoldp.Model
+
+If you define a Meta for your Model, you will [need to explicitly inherit Model.Meta](https://docs.djangoproject.com/fr/2.2/topics/db/models/#meta-inheritance) in order to inherit the default settings, e.g. `default_permissions`
+
+```python
+from djangoldp.models import Model, LDPMetaMixin
+
+class Todo(Model):
+    name = models.CharField(max_length=255)
+
+    class Meta(Model.Meta):
+```
+
+
+See "Custom Meta options" below to see some helpful ways you can tweak the behaviour of DjangoLDP
+
+Your model will be automatically detected and registered with an LDPViewSet and corresponding URLs, as well as being registered with the Django admin panel. If you register your model with the admin panel manually, make sure to extend the GuardedModelAdmin so that the model is registered with [Django-Guardian object permissions](https://django-guardian.readthedocs.io/en/stable/userguide/admin-integration.html)
+
 ## Custom Parameters to LDPViewSet
 
 ### lookup_field
@@ -184,6 +206,10 @@ class MyModel(models.Model):
 
 Now when an instance of `MyModel` is saved, its `author_user` property will be set to the current user. 
 
+## permissions
+
+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
+
 ## permissions_classes
 
 This allows you to add permissions for anonymous, logged in user, author ... in the url:
@@ -303,6 +329,15 @@ MIDDLEWARE = [
 
 Notice tht it'll redirect only HTTP 200 Code.
 
+## Extending DjangoLDP
+
+### Testing
+
+Packaged with DjangoLDP is a tests module, containing unit tests
+
+You can extend these tests and add your own test cases by following the examples in the code. You can then run your tests with:
+`python -m unittest tests.runner`
+
 ## License
 
 Licence MIT
diff --git a/djangoldp/admin.py b/djangoldp/admin.py
index 26279257..90c266b3 100644
--- a/djangoldp/admin.py
+++ b/djangoldp/admin.py
@@ -2,6 +2,7 @@ from importlib import import_module
 
 from django.conf import settings
 from django.contrib import admin
+from guardian.admin import GuardedModelAdmin
 from .models import LDPSource, Model
 
 # automatically import selected DjangoLDP packages from settings
@@ -20,9 +21,10 @@ for package in settings.DJANGOLDP_PACKAGES:
 model_classes = {cls.__name__: cls for cls in Model.__subclasses__()}
 
 # automatically register models with the admin panel (which have not been added manually)
+# NOTE: by default the models are registered with Django Guardian activated
 for class_name in model_classes:
     model_class = model_classes[class_name]
     if not admin.site.is_registered(model_class):
-        admin.site.register(model_class)
+        admin.site.register(model_class, GuardedModelAdmin)
 
 # admin.site.register(LDPSource)
diff --git a/djangoldp/migrations/0004_auto_20200221_1118.py b/djangoldp/migrations/0004_auto_20200221_1118.py
new file mode 100644
index 00000000..50ffde03
--- /dev/null
+++ b/djangoldp/migrations/0004_auto_20200221_1118.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2020-02-21 11:18
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('djangoldp', '0003_auto_20190911_0931'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='ldpsource',
+            options={'ordering': ('federation',)},
+        ),
+    ]
diff --git a/djangoldp/migrations/0005_auto_20200221_1127.py b/djangoldp/migrations/0005_auto_20200221_1127.py
new file mode 100644
index 00000000..b5e3bec8
--- /dev/null
+++ b/djangoldp/migrations/0005_auto_20200221_1127.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2020-02-21 11:27
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('djangoldp', '0004_auto_20200221_1118'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='ldpsource',
+            options={'default_permissions': ('add', 'change', 'delete', 'view', 'control'), 'ordering': ('federation',)},
+        ),
+    ]
diff --git a/djangoldp/models.py b/djangoldp/models.py
index 4aab5204..c61278ba 100644
--- a/djangoldp/models.py
+++ b/djangoldp/models.py
@@ -21,6 +21,7 @@ class Model(models.Model):
 
     @classmethod
     def get_view_set(cls):
+        '''returns the view_set defined in the model Meta or the LDPViewSet class'''
         view_set = getattr(cls._meta, 'view_set', getattr(cls.Meta, 'view_set', None))
         if view_set is None:
             from djangoldp.views import LDPViewSet
@@ -29,6 +30,7 @@ class Model(models.Model):
 
     @classmethod
     def get_container_path(cls):
+        '''returns the url path which is used to access actions on this model (e.g. /users/)'''
         path = getattr(cls._meta, 'container_path', getattr(cls.Meta, 'container_path', None))
         if path is None:
             path = "{}s".format(cls._meta.object_name.lower())
@@ -123,6 +125,7 @@ class Model(models.Model):
 
     @classonlymethod
     def __clean_path(cls, path):
+        '''ensures path is Django-friendly'''
         if not path.startswith("/"):
             path = "/{}".format(path)
         if not path.endswith("/"):
@@ -131,10 +134,12 @@ class Model(models.Model):
 
     @classonlymethod
     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)
 
     @classonlymethod
     def get_meta(cls, model_class, meta_name, default=None):
+        '''returns the models Meta class'''
         if hasattr(model_class, 'Meta'):
             meta = getattr(model_class.Meta, meta_name, default)
         else:
@@ -150,6 +155,7 @@ class Model(models.Model):
 
     @classmethod
     def is_external(cls, value):
+        '''returns True if the urlid of the value passed is from an external source'''
         try:
             return value.urlid is not None and not value.urlid.startswith(settings.SITE_URL)
         except:
@@ -159,15 +165,11 @@ class Model(models.Model):
 class LDPSource(Model):
     federation = models.CharField(max_length=255)
 
-    class Meta:
+    class Meta(Model.Meta):
         rdf_type = 'ldp:Container'
         ordering = ('federation',)
         container_path = 'sources'
         lookup_field = 'federation'
-        permissions = (
-            ('view_source', 'acl:Read'),
-            ('control_source', 'acl:Control'),
-        )
 
     def __str__(self):
         return "{}: {}".format(self.federation, self.urlid)
@@ -189,6 +191,7 @@ if 'djangoldp_account' not in settings.DJANGOLDP_PACKAGES:
         # hack : We user webid as username for external user (since it's an uniq identifier too)
         if validators.url(self.username):
             webid = self.username
+        # unable to use username, so use user-detail URL with primary key
         else:
             webid = '{0}{1}'.format(settings.BASE_URL, reverse_lazy('user-detail', kwargs={'pk': self.pk}))
         return webid
diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py
index b3c61c8f..30c2a340 100644
--- a/djangoldp/permissions.py
+++ b/djangoldp/permissions.py
@@ -1,14 +1,15 @@
 from django.core.exceptions import PermissionDenied
 from django.db.models.base import ModelBase
-from rest_framework.permissions import BasePermission
+from rest_framework.permissions import DjangoObjectPermissions
+from guardian.shortcuts import get_perms
 
 
-class LDPPermissions(BasePermission):
+class LDPPermissions(DjangoObjectPermissions):
     """
         Default permissions
         Anon: None
-        Auth: None but herit from Anon
-        Ownr: None but herit from Auth
+        Auth: None but inherit from Anon
+        Owner: None but inherit from Auth
     """
     anonymous_perms = ['view']
     authenticated_perms = ['inherit']
@@ -19,7 +20,7 @@ class LDPPermissions(BasePermission):
             Filter user permissions for a model class
         """
 
-        # sorted out param mess
+        # this may be a permission for the model class, or an instance
         if isinstance(obj_or_model, ModelBase):
             model = obj_or_model
         else:
@@ -41,23 +42,39 @@ class LDPPermissions(BasePermission):
         if 'inherit' in owner_perms:
             owner_perms = owner_perms + list(set(authenticated_perms) - set(owner_perms))
 
-        if user.is_anonymous():
-            return anonymous_perms
+        # return permissions
+        # apply Django-Guardian (object-level) permissions
+        perms = []
+
+        if obj is not None and not user.is_anonymous:
+            guardian_perms = get_perms(user, obj)
+            model_name = model._meta.model_name
+
+            # remove model name from the permissions
+            forbidden_string = "_" + model_name
+            perms = [p.replace(forbidden_string, '') for p in guardian_perms]
+
+        # apply anon, owner and auth permissions
+        if user.is_anonymous:
+            perms = perms + anonymous_perms
 
         else:
             if obj and hasattr(model._meta, 'owner_field') and (
                     getattr(obj, getattr(model._meta, 'owner_field')) == user
-                    or getattr(obj, getattr(model._meta, 'owner_field')) == user.urlid
+                    or (hasattr(user, 'urlid') and getattr(obj, getattr(model._meta, 'owner_field')) == user.urlid)
                     or getattr(obj, getattr(model._meta, 'owner_field')) == user.id):
-                return owner_perms
+                perms = perms + owner_perms
 
             else:
-                return authenticated_perms
+                perms = perms + authenticated_perms
+
+        return perms
 
     def filter_user_perms(self, user, 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(user, obj_or_model)]
 
+    # perms_map defines the permissions required for different methods
     perms_map = {
         'GET': ['%(app_label)s.view_%(model_name)s'],
         'OPTIONS': [],
@@ -100,8 +117,10 @@ class LDPPermissions(BasePermission):
             obj = Model.resolve_id(request._request.path)
             model = view.model
 
+        # get permissions required
         perms = self.get_permissions(request.method, model)
 
+        # compare them with the permissions I have
         for perm in perms:
             if not perm.split('.')[1].split('_')[0] in self.user_permissions(request.user, model, obj):
                 return False
@@ -119,9 +138,11 @@ class LDPPermissions(BasePermission):
             User have permission on request: Continue
             User does not have permission:   403
         """
+        # get permissions required
         perms = self.get_permissions(request.method, obj)
         model = obj
 
+        # compare them with the permissions I have
         for perm in perms:
             if not perm.split('.')[1].split('_')[0] in self.user_permissions(request.user, model, obj):
                 return False
diff --git a/djangoldp/tests/djangoldp_urls.py b/djangoldp/tests/djangoldp_urls.py
index 6b0d4a0e..c13aad61 100644
--- a/djangoldp/tests/djangoldp_urls.py
+++ b/djangoldp/tests/djangoldp_urls.py
@@ -1,13 +1,15 @@
 from django.conf import settings
 from django.conf.urls import url, include
 
-from djangoldp.tests.models import Skill, JobOffer, Message, Conversation, Dummy, Task
+from djangoldp.permissions import LDPPermissions
+from djangoldp.tests.models import Skill, JobOffer, Message, Conversation, Dummy, PermissionlessDummy, Task
 from djangoldp.views import LDPViewSet
 
 urlpatterns = [
-    url(r'^messages/', LDPViewSet.urls(model=Message, permission_classes=[], fields=["@id", "text", "conversation"], nested_fields=['conversation'])),
-    url(r'^conversations/', LDPViewSet.urls(model=Conversation, nested_fields=["message_set"], permission_classes=())),
-    url(r'^tasks/', LDPViewSet.urls(model=Task, permission_classes=())),
-    url(r'^dummys/', LDPViewSet.urls(model=Dummy, permission_classes=[], lookup_field='slug',)),
+    url(r'^messages/', LDPViewSet.urls(model=Message, permission_classes=[LDPPermissions], fields=["@id", "text", "conversation"], nested_fields=['conversation'])),
+    url(r'^conversations/', LDPViewSet.urls(model=Conversation, nested_fields=["message_set"], permission_classes=[LDPPermissions])),
+    url(r'^tasks/', LDPViewSet.urls(model=Task, permission_classes=[LDPPermissions])),
+    url(r'^dummys/', LDPViewSet.urls(model=Dummy, permission_classes=[LDPPermissions], lookup_field='slug',)),
+    url(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 ede3bec7..01f74515 100644
--- a/djangoldp/tests/models.py
+++ b/djangoldp/tests/models.py
@@ -15,7 +15,7 @@ class Skill(Model):
     def recent_jobs(self):
         return self.joboffer_set.filter(date__gte=date.today())
 
-    class Meta:
+    class Meta(Model.Meta):
         anonymous_perms = ['view']
         authenticated_perms = ['inherit', 'add']
         owner_perms = ['inherit', 'change', 'delete', 'control']
@@ -35,7 +35,7 @@ class JobOffer(Model):
     def some_skill(self):
         return self.skills.all().first()
 
-    class Meta:
+    class Meta(Model.Meta):
         anonymous_perms = ['view']
         authenticated_perms = ['inherit', 'change', 'add']
         owner_perms = ['inherit', 'delete', 'control']
@@ -50,18 +50,19 @@ class Conversation(models.Model):
     author_user = models.ForeignKey(settings.AUTH_USER_MODEL)
     peer_user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name="peers_conv")
 
-    class Meta:
+    class Meta(Model.Meta):
         anonymous_perms = ['view']
         authenticated_perms = ['inherit', 'add']
         owner_perms = ['inherit', 'change', 'delete', 'control']
+        owner_field = 'author_user'
 
 
 class Resource(Model):
     joboffers = models.ManyToManyField(JobOffer, blank=True, related_name='resources')
     description = models.CharField(max_length=255)
 
-    class Meta:
-        anonymous_perms = ['view', 'add', 'delete', 'add', 'change', 'control']
+    class Meta(Model.Meta):
+        anonymous_perms = ['view', 'add', 'delete', 'change', 'control']
         authenticated_perms = ['inherit']
         owner_perms = ['inherit']
         serializer_fields = ["@id", "joboffers"]
@@ -73,7 +74,7 @@ class UserProfile(Model):
     description = models.CharField(max_length=255, blank=True, null=True)
     user = models.OneToOneField(settings.AUTH_USER_MODEL)
 
-    class Meta:
+    class Meta(Model.Meta):
         anonymous_perms = ['view']
         authenticated_perms = ['inherit']
         owner_perms = ['inherit', 'change', 'control']
@@ -85,7 +86,7 @@ class Message(models.Model):
     conversation = models.ForeignKey(Conversation, on_delete=models.DO_NOTHING)
     author_user = models.ForeignKey(settings.AUTH_USER_MODEL)
 
-    class Meta:
+    class Meta(Model.Meta):
         anonymous_perms = ['view']
         authenticated_perms = ['inherit', 'add']
         owner_perms = ['inherit', 'change', 'delete', 'control']
@@ -95,7 +96,7 @@ class Dummy(models.Model):
     some = models.CharField(max_length=255, blank=True, null=True)
     slug = models.SlugField(blank=True, null=True, unique=True)
 
-    class Meta:
+    class Meta(Model.Meta):
         anonymous_perms = ['view']
         authenticated_perms = ['inherit', 'add']
         owner_perms = ['inherit', 'change', 'delete', 'control']
@@ -104,17 +105,31 @@ class Dummy(models.Model):
 class LDPDummy(Model):
     some = models.CharField(max_length=255, blank=True, null=True)
 
-    class Meta:
+    class Meta(Model.Meta):
         anonymous_perms = ['view']
         authenticated_perms = ['inherit', 'add']
         owner_perms = ['inherit', 'change', 'delete', 'control']
 
 
+# model used in django-guardian permission tests (no anonymous etc permissions set)
+class PermissionlessDummy(Model):
+    some = models.CharField(max_length=255, blank=True, null=True)
+    slug = models.SlugField(blank=True, null=True, unique=True)
+
+    class Meta(Model.Meta):
+        anonymous_perms = []
+        authenticated_perms = []
+        owner_perms = []
+        permissions = (
+            ('custom_permission_permissionlessdummy', 'Custom Permission'),
+        )
+
+
 class Invoice(Model):
     title = models.CharField(max_length=255, blank=True, null=True)
     date = models.DateField(blank=True, null=True)
 
-    class Meta:
+    class Meta(Model.Meta):
         depth = 2
         anonymous_perms = ['view']
         authenticated_perms = ['inherit', 'add']
@@ -126,7 +141,7 @@ class Batch(Model):
     invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='batches')
     title = models.CharField(max_length=255, blank=True, null=True)
 
-    class Meta:
+    class Meta(Model.Meta):
         serializer_fields = ['@id', 'title', 'invoice', 'tasks']
         anonymous_perms = ['view', 'add']
         authenticated_perms = ['inherit', 'add']
@@ -139,7 +154,7 @@ class Task(models.Model):
     batch = models.ForeignKey(Batch, on_delete=models.CASCADE, related_name='tasks')
     title = models.CharField(max_length=255)
 
-    class Meta:
+    class Meta(Model.Meta):
         serializer_fields = ['@id', 'title', 'batch']
         anonymous_perms = ['view']
         authenticated_perms = ['inherit', 'add']
@@ -151,7 +166,7 @@ class Post(Model):
     author = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True)
     peer_user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name="peers_post")
 
-    class Meta:
+    class Meta(Model.Meta):
         auto_author = 'author'
         anonymous_perms = ['view', 'add', 'delete', 'add', 'change', 'control']
         authenticated_perms = ['inherit']
@@ -162,7 +177,7 @@ class Circle(Model):
     description = models.CharField(max_length=255)
     team = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
 
-    class Meta:
+    class Meta(Model.Meta):
         nested_fields = ["team"]
         anonymous_perms = ['view', 'add', 'delete', 'add', 'change', 'control']
         authenticated_perms = ["inherit"]
diff --git a/djangoldp/tests/runner.py b/djangoldp/tests/runner.py
index f0fb6b43..7416c711 100644
--- a/djangoldp/tests/runner.py
+++ b/djangoldp/tests/runner.py
@@ -65,6 +65,7 @@ failures = test_runner.run_tests([
     'djangoldp.tests.tests_ldp_model',
     'djangoldp.tests.tests_save',
     'djangoldp.tests.tests_user_permissions',
+    'djangoldp.tests.tests_guardian',
     'djangoldp.tests.tests_anonymous_permissions',
     'djangoldp.tests.tests_update',
     'djangoldp.tests.tests_auto_author',
@@ -73,7 +74,6 @@ failures = test_runner.run_tests([
     'djangoldp.tests.tests_sources',
     'djangoldp.tests.tests_pagination',
     # 'djangoldp.tests.tests_temp'
-
 ])
 if failures:
     sys.exit(failures)
diff --git a/djangoldp/tests/tests_guardian.py b/djangoldp/tests/tests_guardian.py
new file mode 100644
index 00000000..04bc2bb0
--- /dev/null
+++ b/djangoldp/tests/tests_guardian.py
@@ -0,0 +1,92 @@
+import json
+from django.contrib.auth import get_user_model
+from rest_framework.test import APIClient, APITestCase
+from guardian.shortcuts import assign_perm
+
+from .models import PermissionlessDummy
+from djangoldp.permissions import LDPPermissions
+
+
+class TestsGuardian(APITestCase):
+
+    def setUp(self):
+        self.client = APIClient(enforce_csrf_checks=True)
+
+    def setUpLoggedInUser(self):
+        self.user = get_user_model().objects.create_user(username='john', email='jlennon@beatles.com',
+                                                         password='glass onion')
+        self.client.force_authenticate(user=self.user)
+
+    # optional setup for testing PermissionlessDummy model with parameterised perms
+    def setUpGuardianDummyWithPerms(self, perms=[]):
+        self.dummy = PermissionlessDummy.objects.create(some='test', slug='test')
+        model_name = PermissionlessDummy._meta.model_name
+
+        for perm in perms:
+            assign_perm(perm + '_' + model_name, self.user, self.dummy)
+
+    # 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)
+
+    # 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)
+
+    # tests that dummy with permissions set enforces these permissions
+    def test_list_dummy_permission_granted(self):
+        self.setUpLoggedInUser()
+        self.setUpGuardianDummyWithPerms(['view'])
+        response = self.client.get('/permissionless-dummys/')
+        self.assertEqual(response.status_code, 200)
+
+    def test_get_dummy_permission_granted(self):
+        self.setUpLoggedInUser()
+        self.setUpGuardianDummyWithPerms(['view'])
+        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)
+
+    def test_post_dummy_permission_granted(self):
+        self.setUpLoggedInUser()
+        self.setUpGuardianDummyWithPerms(['add'])
+        post = {'some': "some_new", "slug": 'slug1'}
+        response = self.client.post('/permissionless-dummys/', data=json.dumps(post), content_type='application/ld+json')
+        self.assertEqual(response.status_code, 201)
+
+    def test_patch_dummy_permission_granted(self):
+        self.setUpLoggedInUser()
+        self.setUpGuardianDummyWithPerms(['change'])
+        body = {'some': "some_new"}
+        response = self.client.patch('/permissionless-dummys/{}/'.format(self.dummy.slug), data=json.dumps(body),
+                                   content_type='application/ld+json')
+        self.assertEqual(response.status_code, 200)
+
+    def test_patch_dummy_permission_rejected(self):
+        self.setUpLoggedInUser()
+        self.setUpGuardianDummyWithPerms(['change'])
+        dummy_without = PermissionlessDummy.objects.create(some='test2', slug='test2')
+        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)
+
+    # test that custom permissions are returned on a model
+    def test_custom_permissions(self):
+        self.setUpLoggedInUser()
+        self.setUpGuardianDummyWithPerms(['custom_permission'])
+
+        permissions = LDPPermissions()
+        result = permissions.user_permissions(self.user, self.dummy)
+        self.assertIn('custom_permission', result)
diff --git a/djangoldp/tests/tests_update.py b/djangoldp/tests/tests_update.py
index ebb914a3..ae35ebe2 100644
--- a/djangoldp/tests/tests_update.py
+++ b/djangoldp/tests/tests_update.py
@@ -13,6 +13,9 @@ class Update(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)
 
     def tearDown(self):
         pass
@@ -340,9 +343,8 @@ class Update(TestCase):
         self.assertIn('conversation_set', response.data)
 
     def test_missing_field_should_not_be_removed_with_fk_relation(self):
-        user = get_user_model().objects.create(username="alex", password="test")
         peer = get_user_model().objects.create(username="sylvain", password="test2")
-        conversation = Conversation.objects.create(author_user=user, peer_user=peer,
+        conversation = Conversation.objects.create(author_user=self.user, peer_user=peer,
                                                    description="conversation description")
         body = [
             {
@@ -356,9 +358,8 @@ class Update(TestCase):
         self.assertIn('peer_user', response.data)
 
     def test_empty_field_should_be_removed_with_fk_relation(self):
-        user = get_user_model().objects.create(username="alex", password="test")
         peer = get_user_model().objects.create(username="sylvain", password="test2")
-        conversation = Conversation.objects.create(author_user=user, peer_user=peer,
+        conversation = Conversation.objects.create(author_user=self.user, peer_user=peer,
                                                    description="conversation description")
         body = [
             {
@@ -485,15 +486,14 @@ class Update(TestCase):
         self.assertEqual(response.data['joboffers']['ldp:contains'][0]['title'], "first title")
 
     def test_update_with_new_fk_relation(self):
-        user = get_user_model().objects.create(username="alex", password="test")
-        conversation = Conversation.objects.create(author_user=user,
+        conversation = Conversation.objects.create(author_user=self.user,
                                                    description="conversation description")
         body = [
             {
                 '@id': "/conversations/{}/".format(conversation.pk),
                 'http://happy-dev.fr/owl/#description': "conversation update",
                 'http://happy-dev.fr/owl/#peer_user': {
-                    '@id': 'http://happy-dev.fr/users/{}'.format(user.pk),
+                    '@id': 'http://happy-dev.fr/users/{}'.format(self.user.pk),
                 }
             }
         ]
@@ -505,7 +505,7 @@ class Update(TestCase):
         conversation = Conversation.objects.get(pk=conversation.pk)
         self.assertIsNotNone(conversation.peer_user)
 
-        user = get_user_model().objects.get(pk=user.pk)
+        user = get_user_model().objects.get(pk=self.user.pk)
         self.assertEqual(user.peers_conv.count(), 1)
 
     def test_m2m_user_link_federated(self):
diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py
index 96ae9673..f7c17f16 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 rest_framework.test import APIClient, APITestCase
+from guardian.shortcuts import assign_perm
 
 from djangoldp.permissions import LDPPermissions
-from .models import JobOffer
+from .models import JobOffer, PermissionlessDummy
 from djangoldp.views import LDPViewSet
 
 import json
@@ -10,9 +11,9 @@ import json
 class TestUserPermissions(APITestCase):
 
     def setUp(self):
-        user = get_user_model().objects.create_user(username='john', email='jlennon@beatles.com', password='glass onion')
+        self.user = get_user_model().objects.create_user(username='john', email='jlennon@beatles.com', password='glass onion')
         self.client = APIClient(enforce_csrf_checks=True)
-        self.client.force_authenticate(user=user)
+        self.client.force_authenticate(user=self.user)
         self.job = JobOffer.objects.create(title="job", slug="slug1")
 
     def test_get_for_authenticated_user(self):
diff --git a/djangoldp/urls.py b/djangoldp/urls.py
index 39ff5152..4a9fb6d4 100644
--- a/djangoldp/urls.py
+++ b/djangoldp/urls.py
@@ -10,6 +10,7 @@ from djangoldp.views import LDPViewSet
 
 
 def __clean_path(path):
+    '''ensures path is Django-friendly'''
     if path.startswith("/"):
         path = path[1:]
     if not path.endswith("/"):
@@ -29,11 +30,15 @@ for package in settings.DJANGOLDP_PACKAGES:
     except ModuleNotFoundError:
         pass
 
+# fetch a list of all models which subclass DjangoLDP Model
 model_classes = {cls.__name__: cls for cls in Model.__subclasses__()}
 
+# append urls for all DjangoLDP Model subclasses
 for class_name in model_classes:
     model_class = model_classes[class_name]
+    # the path is the url for this model
     path = __clean_path(model_class.get_container_path())
+    # urls_fct will be a method which generates urls for a ViewSet (defined in LDPViewSet)
     urls_fct = model_class.get_view_set().urls
     urlpatterns.append(url(r'^' + path, include(
         urls_fct(model=model_class,
-- 
GitLab