diff --git a/README.md b/README.md index bf25d9b01dd82e2a0c3f91b22fef84544756ee6b..57feef4bd1c219340226492e50275b2d1f54fc0a 100644 --- a/README.md +++ b/README.md @@ -182,14 +182,31 @@ class Todo(Model): class Meta(Model.Meta): ``` +To enable federation, meaning that local users can access objects from another server as if they were on the local server, DjangoLDP creates backlinks, local copies of the object containing the URL-id (@id) of the distant resource. This is a key concept in LDP. To read more, see the [W3C primer on LDP](https://www.w3.org/TR/ldp-primer/), and the [LDP specification](https://www.w3.org/TR/ldp/) + +For situations where you don't want to include federated resources in a queryset, DjangoLDP Models override `models.Manager`, allowing you to write `Todo.objects.local()`, for example: +```python +Todo.objects.create(name='Local Todo') +Todo.objects.create(name='Distant Todo', urlid='https://anotherserversomewhere.com/todos/1/') + +Todo.objects.all() # query set containing { Local Todo, Distant Todo } +Todo.objects.local() # { Local Todo } only +``` + +For Views, we also define a FilterBackend to achieve the same purpose. See the section on ViewSets for this purpose + 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 +## LDPViewSet + +DjangoLDP automatically generates ViewSets for your models, and registers these at urls, according to the settings configured in the model Meta (see below for options) -### lookup_field +### Custom Parameters + +#### lookup_field Can be used to use a slug in the url instead of the primary key. @@ -197,7 +214,7 @@ Can be used to use a slug in the url instead of the primary key. LDPViewSet.urls(model=User, lookup_field='username') ``` -### nested_fields +#### nested_fields list of ForeignKey, ManyToManyField, OneToOneField and their reverse relations. When a field is listed in this parameter, a container will be created inside each single element of the container. @@ -207,6 +224,37 @@ In the following example, besides the urls `/members/` and `/members/<pk>/`, two <Model>._meta.nested_fields=["skills"] ``` +## Filter Backends + +To achieve federation, DjangoLDP includes links to objects from federated servers and stores these as local objects (see 1.0 - Models). In some situations, you will want to exclude these from the queryset of a custom view + +To provide for this need, there is defined in `djangoldp.filters` a FilterBackend which can be included in custom viewsets to restrict the queryset to only objects which were created locally: + +```python +from djangoldp.filters import LocalObjectFilterBackend + +class MyViewSet(..): + filter_backends=[LocalObjectFilterBackend] +``` + +By default, LDPViewset applies filter backends from the `permission_classes` defined on the model (see 3.1 for configuration) + +By default, `LDPViewSets` use another FilterBackend, `LocalObjectOnContainerPathBackend`, which ensures that only local objects are returned when the path matches that of the Models `container_path` (e.g. /users/ will return a list of local users). In very rare situations where this might be undesirable, it's possible to extend `LDPViewSet` and remove the filter_backend: + +```python +class LDPSourceViewSet(LDPViewSet): + model = LDPSource + filter_backends = [] +``` + +Following this you will need to update the model's Meta to use the custom `view_set`: + +```python +class Meta: + view_set = LDPSourceViewSet +``` + + ## Custom Meta options on models ### rdf_type diff --git a/djangoldp/filters.py b/djangoldp/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..59afd6b909bf8d20a6425fa20c29bc225b3ac6a2 --- /dev/null +++ b/djangoldp/filters.py @@ -0,0 +1,23 @@ +from rest_framework.filters import BaseFilterBackend +from djangoldp.models import Model + + +class LocalObjectFilterBackend(BaseFilterBackend): + """ + Filter which removes external objects (federated backlinks) from the queryset + For querysets which should only include local objects + """ + def filter_queryset(self, request, queryset, view): + internal_ids = [x.pk for x in queryset if not Model.is_external(x)] + return queryset.filter(pk__in=internal_ids) + + +class LocalObjectOnContainerPathBackend(LocalObjectFilterBackend): + """ + Override of LocalObjectFilterBackend which removes external objects when the view requested + is the model container path + """ + def filter_queryset(self, request, queryset, view): + if issubclass(view.model, Model) and request.path_info == view.model.get_container_path(): + return super(LocalObjectOnContainerPathBackend, self).filter_queryset(request, queryset, view) + return queryset diff --git a/djangoldp/models.py b/djangoldp/models.py index 4bc67125b9b7f2953ddda755d189eb8c2a03a0fa..f76de19fade43084f6044778dd386f8671ca2a0b 100644 --- a/djangoldp/models.py +++ b/djangoldp/models.py @@ -13,8 +13,17 @@ from djangoldp.fields import LDPUrlField from djangoldp.permissions import LDPPermissions +class LDPModelManager(models.Manager): + # an alternative to all() which exlcudes external resources + def local(self): + queryset = super(LDPModelManager, self).all() + internal_ids = [x.pk for x in queryset if not Model.is_external(x)] + return queryset.filter(pk__in=internal_ids) + + class Model(models.Model): urlid = LDPUrlField(blank=True, null=True, unique=True) + objects = LDPModelManager() def __init__(self, *args, **kwargs): super(Model, self).__init__(*args, **kwargs) diff --git a/djangoldp/tests/tests_get.py b/djangoldp/tests/tests_get.py index e69f119f23cbef0094600ba50b1c330f6c58f360..adda7c7b1a3357e75c2fa383836f5be8f5d8c9a6 100644 --- a/djangoldp/tests/tests_get.py +++ b/djangoldp/tests/tests_get.py @@ -23,9 +23,12 @@ class TestGET(APITestCase): def test_get_container(self): Post.objects.create(content="content") + # federated object - should not be returned in the container view + 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") @@ -38,6 +41,7 @@ class TestGET(APITestCase): Post.objects.all().delete() response = self.client.get('/posts/', content_type='application/ld+json') self.assertEqual(response.status_code, 200) + self.assertEquals(0, len(response.data['ldp:contains'])) def test_get_filtered_fields(self): skill = Skill.objects.create(title="Java", obligatoire="ok", slug="1") @@ -79,6 +83,8 @@ class TestGET(APITestCase): def test_get_nested(self): invoice = Invoice.objects.create(title="invoice") batch = Batch.objects.create(invoice=invoice, title="batch") + distant_batch = Batch.objects.create(invoice=invoice, title="distant", urlid="https://external.com/batch/1/") response = self.client.get('/invoices/{}/batches/'.format(invoice.pk), content_type='application/ld+json') self.assertEqual(response.status_code, 200) self.assertEquals(response.data['@id'], 'http://happy-dev.fr/invoices/{}/batches/'.format(invoice.pk)) + self.assertEquals(len(response.data['ldp:contains']), 2) diff --git a/djangoldp/tests/tests_sources.py b/djangoldp/tests/tests_sources.py index 94b58df8823137557a5992075d92ed40b7102620..374d982159b4efbd3cfb39e1e9491abaa5ac6923 100644 --- a/djangoldp/tests/tests_sources.py +++ b/djangoldp/tests/tests_sources.py @@ -17,8 +17,10 @@ class TestSource(APITestCase): response = self.client.get('/sources/{}/'.format(source.federation), content_type='application/ld+json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['@id'], 'http://happy-dev.fr/sources/source_name/') + self.assertEqual(len(response.data['ldp:contains']), 1) def test_get_empty_resource(self): response = self.client.get('/sources/{}/'.format('unknown'), content_type='application/ld+json') self.assertEqual(response.status_code, 200) self.assertEqual(response.data['@id'], 'http://happy-dev.fr/sources/unknown/') + self.assertEqual(len(response.data['ldp:contains']), 0) diff --git a/djangoldp/views.py b/djangoldp/views.py index 16153b9f9fdc35f4ddb80458377e617ce3d895f4..720f4ceffa5ebd73081fa4c9c55fa255f84b168a 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -19,6 +19,7 @@ from rest_framework.viewsets import ModelViewSet from djangoldp.endpoints.webfinger import WebFingerEndpoint, WebFingerError from djangoldp.models import LDPSource, Model from djangoldp.permissions import LDPPermissions +from djangoldp.filters import LocalObjectOnContainerPathBackend get_user_model()._meta.rdf_context = {"get_full_name": "rdfs:label"} @@ -114,6 +115,7 @@ class LDPViewSet(LDPViewSetGenerator): renderer_classes = (JSONLDRenderer,) parser_classes = (JSONLDParser,) authentication_classes = (NoCSRFAuthentication,) + filter_backends = [LocalObjectOnContainerPathBackend] def __init__(self, **kwargs): super().__init__(**kwargs) @@ -122,7 +124,7 @@ class LDPViewSet(LDPViewSetGenerator): if self.permission_classes: for p in self.permission_classes: if hasattr(p, 'filter_class') and p.filter_class: - self.filter_backends = p.filter_class + self.filter_backends.append(p.filter_class) self.serializer_class = self.build_read_serializer() self.write_serializer_class = self.build_write_serializer() @@ -301,6 +303,7 @@ class LDPNestedViewSet(LDPViewSet): class LDPSourceViewSet(LDPViewSet): model = LDPSource federation = None + filter_backends = [] def get_queryset(self, *args, **kwargs): return super().get_queryset(*args, **kwargs).filter(federation=self.kwargs['federation'])