Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • djangoldp-packages/djangoldp
  • decentral1se/djangoldp
  • femmefaytale/djangoldp
  • jvtrudel/djangoldp
4 results
Show changes
Showing
with 1284 additions and 157 deletions
from importlib import import_module
from django.conf import settings
from django.conf.urls import url, include
from django.contrib.auth.models import Group
from django.urls import path, re_path, include
from djangoldp.models import LDPSource, Model
from djangoldp.permissions import LDPPermissions
from djangoldp.views import LDPSourceViewSet, WebFingerView
from djangoldp.views import LDPViewSet
from djangoldp.permissions import ReadOnly
from djangoldp.views import LDPSourceViewSet, WebFingerView, InboxView
from djangoldp.views import LDPViewSet, serve_static_content
def __clean_path(path):
......@@ -18,40 +19,66 @@ def __clean_path(path):
return path
def get_all_non_abstract_subclasses(cls):
'''
returns a set of all subclasses for a given Python class (recursively calls cls.__subclasses__()). Ignores Abstract
classes
'''
def valid_subclass(sc):
'''returns True if the parameterised subclass is valid and should be returned'''
return not getattr(sc._meta, 'abstract', False)
return set(c for c in cls.__subclasses__() if valid_subclass(c)).union(
[subclass for c in cls.__subclasses__() for subclass in get_all_non_abstract_subclasses(c) if valid_subclass(subclass)])
urlpatterns = [
url(r'^sources/(?P<federation>\w+)/', LDPSourceViewSet.urls(model=LDPSource, fields=['federation', 'urlid'],
permission_classes=[LDPPermissions], )),
url(r'^\.well-known/webfinger/?$', WebFingerView.as_view()),
path('groups/', LDPViewSet.urls(model=Group)),
re_path(r'^sources/(?P<federation>\w+)/', LDPSourceViewSet.urls(model=LDPSource, fields=['federation', 'urlid'],
permission_classes=[ReadOnly], )),
re_path(r'^\.well-known/webfinger/?$', WebFingerView.as_view()),
path('inbox/', InboxView.as_view()),
re_path(r'^ssr/(?P<path>.*)$', serve_static_content, name='serve_static_content'),
]
if settings.ENABLE_SWAGGER_DOCUMENTATION:
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns.extend([
path("schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"docs/",
SpectacularSwaggerView.as_view(
template_name="swagger-ui.html", url_name="schema"
),
name="swagger-ui",
)
])
for package in settings.DJANGOLDP_PACKAGES:
try:
import_module('{}.models'.format(package))
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,
lookup_field=Model.get_meta(model_class, 'lookup_field', 'pk'),
permission_classes=Model.get_meta(model_class, 'permission_classes', [LDPPermissions]),
fields=Model.get_meta(model_class, 'serializer_fields', []),
nested_fields=Model.get_meta(model_class, 'nested_fields', [])))))
for package in settings.DJANGOLDP_PACKAGES:
try:
urlpatterns.append(url(r'^', include('{}.djangoldp_urls'.format(package))))
urlpatterns.append(path('', include('{}.djangoldp_urls'.format(package))))
except ModuleNotFoundError:
pass
if 'djangoldp_account' not in settings.DJANGOLDP_PACKAGES:
urlpatterns.append(url(r'^users/', LDPViewSet.urls(model=settings.AUTH_USER_MODEL, permission_classes=[])))
# append urls for all DjangoLDP Model subclasses
for model in get_all_non_abstract_subclasses(Model):
# the path is the url for this model
model_path = __clean_path(model.get_container_path())
# urls_fct will be a method which generates urls for a ViewSet (defined in LDPViewSetGenerator)
urls_fct = getattr(model, 'view_set', LDPViewSet).urls
disable_url = getattr(model._meta, 'disable_url', False)
if not disable_url:
urlpatterns.append(path('' + model_path,
urls_fct(model=model,
lookup_field=getattr(model._meta, 'lookup_field', 'pk'),
permission_classes=getattr(model._meta, 'permission_classes', []),
fields=getattr(model._meta, 'serializer_fields', []),
nested_fields=getattr(model._meta, 'nested_fields', [])
)))
# NOTE: this route will be ignored if a custom (subclass of Model) user model is used, or it is registered by a package
# Django matches the first url it finds for a given path
urlpatterns.append(re_path('users/', LDPViewSet.urls(model=settings.AUTH_USER_MODEL, permission_classes=[])))
\ No newline at end of file
from django.conf import settings
from guardian.utils import get_anonymous_user
PASSTHROUGH_IPS = getattr(settings, 'PASSTHROUGH_IPS', '')
# convenience function returns True if user is anonymous
def is_anonymous_user(user):
anonymous_username = getattr(settings, 'ANONYMOUS_USER_NAME', None)
return user.is_anonymous or user.username == anonymous_username
# convenience function returns True if user is authenticated
def is_authenticated_user(user):
anonymous_username = getattr(settings, 'ANONYMOUS_USER_NAME', None)
return user.is_authenticated and user.username != anonymous_username
# this method is used to check if a given IP is part of the PASSTHROUGH_IPS list
def check_client_ip(request):
x_forwarded_for = request.headers.get('x-forwarded-for')
if x_forwarded_for:
if any(ip in x_forwarded_for.replace(' ', '').split(',') for ip in PASSTHROUGH_IPS):
return True
elif request.META.get('REMOTE_ADDR') in PASSTHROUGH_IPS:
return True
return False
import json
import logging
import os
import time
import validators
from django.apps import apps
from django.conf import settings
from django.conf.urls import url, include
from django.contrib.auth import get_user_model
from django.core.exceptions import FieldDoesNotExist
from django.core.urlresolvers import get_resolver
from django.http import JsonResponse
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
from django.db import IntegrityError, transaction
from django.http import Http404, HttpResponseNotFound, JsonResponse
from django.shortcuts import get_object_or_404
from django.urls import include, path, re_path
from django.urls.resolvers import get_resolver
from django.utils.decorators import classonlymethod
from django.views import View
from pyld import jsonld
from rest_framework import status
from rest_framework.authentication import SessionAuthentication
from rest_framework.exceptions import ParseError
from rest_framework.parsers import JSONParser
from rest_framework.permissions import AllowAny
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.utils import model_meta
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from djangoldp.activities import (ACTIVITY_SAVING_SETTING, ActivityPubService,
ActivityQueueService, as_activitystream)
from djangoldp.activities.errors import (ActivityStreamDecodeError,
ActivityStreamValidationError)
from djangoldp.endpoints.webfinger import WebFingerEndpoint, WebFingerError
from djangoldp.models import LDPSource, Model
from djangoldp.permissions import LDPPermissions
from djangoldp.filters import (LocalObjectOnContainerPathBackend,
SearchByQueryParamFilterBackend)
from djangoldp.models import DynamicNestedField, Follower, LDPSource, Model
from djangoldp.related import get_prefetch_fields
from djangoldp.utils import is_authenticated_user
logger = logging.getLogger('djangoldp')
get_user_model()._meta.rdf_context = {"get_full_name": "rdfs:label"}
......@@ -40,15 +59,20 @@ class JSONLDRenderer(JSONRenderer):
data["@context"] = settings.LDP_RDF_CONTEXT
return super(JSONLDRenderer, self).render(data, accepted_media_type, renderer_context)
# https://github.com/digitalbazaar/pyld
class JSONLDParser(JSONParser):
#TODO: It current only works with pyld 1.0. We need to check our support of JSON-LD
media_type = 'application/ld+json'
def parse(self, stream, media_type=None, parser_context=None):
data = super(JSONLDParser, self).parse(stream, media_type, parser_context)
# compact applies the context to the data and makes it a format which is easier to work with
# see: http://json-ld.org/spec/latest/json-ld/#compacted-document-form
return jsonld.compact(data, ctx=settings.LDP_RDF_CONTEXT)
try:
return jsonld.compact(data, ctx=settings.LDP_RDF_CONTEXT)
except jsonld.JsonLdError as e:
raise ParseError(str(e.cause))
# an authentication class which exempts CSRF authentication
......@@ -57,6 +81,241 @@ class NoCSRFAuthentication(SessionAuthentication):
return
class InboxView(APIView):
"""
Receive linked data notifications
"""
permission_classes = [AllowAny, ]
renderer_classes = [JSONLDRenderer]
def post(self, request, *args, **kwargs):
'''
receiver for inbox messages. See https://www.w3.org/TR/ldn/
'''
try:
activity = json.loads(request.body, object_hook=as_activitystream)
activity.validate()
except ActivityStreamDecodeError:
return Response('Activity type unsupported', status=status.HTTP_405_METHOD_NOT_ALLOWED)
except ActivityStreamValidationError as e:
return Response(str(e), status=status.HTTP_400_BAD_REQUEST)
try:
self._handle_activity(activity, **kwargs)
except IntegrityError:
return Response({'Unable to save due to an IntegrityError in the receiver model'},
status=status.HTTP_200_OK)
except ValueError as e:
return Response(str(e), status=status.HTTP_400_BAD_REQUEST)
# save the activity and return 201
if ACTIVITY_SAVING_SETTING == 'VERBOSE':
obj = ActivityQueueService._save_sent_activity(activity.to_json(), local_id=request.path_info, success=True,
type=activity.type)
response = Response({}, status=status.HTTP_201_CREATED)
response['Location'] = obj.urlid
else:
response = Response({}, status=status.HTTP_200_OK)
return response
def _handle_activity(self, activity, **kwargs):
if activity.type == 'Add':
self.handle_add_activity(activity, **kwargs)
elif activity.type == 'Remove':
self.handle_remove_activity(activity, **kwargs)
elif activity.type == 'Delete':
self.handle_delete_activity(activity, **kwargs)
elif activity.type == 'Create' or activity.type == 'Update':
self.handle_create_or_update_activity(activity, **kwargs)
elif activity.type == 'Follow':
self.handle_follow_activity(activity, **kwargs)
def atomic_get_or_create_nested_backlinks(self, obj, object_model=None, update=False):
'''
a version of get_or_create_nested_backlinks in which all nested backlinks are created, or none of them are
'''
try:
with transaction.atomic():
return self._get_or_create_nested_backlinks(obj, object_model, update)
except IntegrityError as e:
logger.error(str(e))
logger.warning(
'received a backlink which you were not able to save because of a constraint on the model field.')
raise e
def _get_or_create_nested_backlinks(self, obj, object_model=None, update=False):
'''
recursively deconstructs a tree of nested objects, using get_or_create on each leaf/branch
:param obj: Dict representation of the object
:param object_model: The Model class of the object. Will be discovered if set to None
:param update: if True will update retrieved objects with new data
:raises Exception: if get_or_create fails on a branch, the creation will be reversed and the Exception re-thrown
'''
# store a list of the object's sub-items
if object_model is None:
object_model = Model.get_subclass_with_rdf_type(obj['@type'])
if object_model is None:
raise Http404('unable to store type ' + obj['@type'] + ', model with this rdf_type not found')
branches = {}
for item in obj.items():
# TODO: parse other data types. Match the key to the field_name
if isinstance(item[1], dict):
item_value = item[1]
item_model = Model.get_subclass_with_rdf_type(item_value['@type'])
if item_model is None:
raise Http404(
'unable to store type ' + item_value['@type'] + ', model with this rdf_type not found')
# push nested object tuple as a branch
backlink = self._get_or_create_nested_backlinks(item_value, item_model)
branches[item[0]] = backlink
# get or create the backlink
try:
if obj['@id'] is None or not validators.url(obj['@id']):
raise ValueError('received invalid urlid ' + str(obj['@id']))
external = Model.get_or_create_external(object_model, obj['@id'], update=update, **branches)
# creating followers, to inform distant resource of changes to local connection
if Model.is_external(external):
# this is handled with Followers, where each local child of the branch is followed by its external parent
for item in obj.items():
urlid = item[1]
if isinstance(item[1], dict):
urlid = urlid['@id']
if not isinstance(urlid, str) or not validators.url(urlid):
continue
if not Model.is_external(urlid):
ActivityPubService.save_follower_for_target(external.urlid, urlid)
return external
# this will be raised when the object was local, but it didn't exist
except ObjectDoesNotExist:
raise Http404(getattr(object_model._meta, 'label', 'Unknown Model') + ' ' + str(obj['@id']) + ' does not exist')
# TODO: a fallback here? Saving the backlink as Object or similar
def _get_subclass_with_rdf_type_or_404(self, rdf_type):
model = Model.get_subclass_with_rdf_type(rdf_type)
if model is None:
raise Http404('unable to store type ' + rdf_type + ', model not found')
return model
def handle_add_activity(self, activity, **kwargs):
'''
handles Add Activities. See https://www.w3.org/ns/activitystreams
Indicates that the actor has added the object to the target
'''
object_model = self._get_subclass_with_rdf_type_or_404(activity.object['@type'])
target_model = self._get_subclass_with_rdf_type_or_404(activity.target['@type'])
try:
target = target_model.objects.get(urlid=activity.target['@id'])
except target_model.DoesNotExist:
return Response({}, status=status.HTTP_404_NOT_FOUND)
# store backlink(s) in database
backlink = self.atomic_get_or_create_nested_backlinks(activity.object, object_model)
# add object to target
target_info = model_meta.get_field_info(target_model)
for field_name, relation_info in target_info.relations.items():
if relation_info.related_model == object_model:
attr = getattr(target, field_name)
if not attr.filter(urlid=backlink.urlid).exists():
attr.add(backlink)
ActivityPubService.save_follower_for_target(backlink.urlid, target.urlid)
def handle_remove_activity(self, activity, **kwargs):
'''
handles Remove Activities. See https://www.w3.org/ns/activitystreams
Indicates that the actor has removed the object from the origin
'''
# TODO: Remove Activity may pass target instead
object_model = self._get_subclass_with_rdf_type_or_404(activity.object['@type'])
origin_model = self._get_subclass_with_rdf_type_or_404(activity.origin['@type'])
# get the model reference to saved object
try:
origin = origin_model.objects.get(urlid=activity.origin['@id'])
object_instance = object_model.objects.get(urlid=activity.object['@id'])
except origin_model.DoesNotExist:
raise Http404(activity.origin['@id'] + ' did not exist')
except object_model.DoesNotExist:
return
# remove object from origin
origin_info = model_meta.get_field_info(origin_model)
for field_name, relation_info in origin_info.relations.items():
if relation_info.related_model == object_model:
attr = getattr(origin, field_name)
if attr.filter(urlid=object_instance.urlid).exists():
attr.remove(object_instance)
ActivityPubService.remove_followers_for_resource(origin.urlid, object_instance.urlid)
def handle_create_or_update_activity(self, activity, **kwargs):
'''
handles Create & Update Activities. See https://www.w3.org/ns/activitystreams
'''
object_model = self._get_subclass_with_rdf_type_or_404(activity.object['@type'])
self.atomic_get_or_create_nested_backlinks(activity.object, object_model, update=True)
def handle_delete_activity(self, activity, **kwargs):
'''
handles Remove Activities. See https://www.w3.org/ns/activitystreams
Indicates that the actor has deleted the object
'''
object_model = self._get_subclass_with_rdf_type_or_404(activity.object['@type'])
# get the model reference to saved object
try:
object_instance = object_model.objects.get(urlid=activity.object['@id'])
except object_model.DoesNotExist:
return
# disable backlinks first - prevents a duplicate being sent back
object_instance.allow_create_backlink = False
object_instance.save()
object_instance.delete()
urlid = getattr(object_instance, 'urlid', None)
if urlid is not None:
for follower in Follower.objects.filter(follower=urlid):
follower.delete()
def handle_follow_activity(self, activity, **kwargs):
'''
handles Follow Activities. See https://www.w3.org/ns/activitystreams
Indicates that the actor is following the object, and should receive Updates on what happens to it
'''
object_model = self._get_subclass_with_rdf_type_or_404(activity.object['@type'])
# get the model reference to saved object
try:
object_instance = object_model.objects.get(urlid=activity.object['@id'])
except object_model.DoesNotExist:
raise Http404(activity.object['@id'] + ' did not exist')
if Model.is_external(object_instance):
raise Http404(activity.object['@id'] + ' is not local to this server')
# get the inbox field from the actor
if isinstance(activity.actor, str):
inbox = activity.actor
else:
inbox = getattr(activity.actor, 'inbox', None)
if inbox is None:
inbox = getattr(activity.actor, 'id', getattr(activity.actor, '@id'))
if not Follower.objects.filter(object=object_instance.urlid, inbox=inbox).exists():
Follower.objects.create(object=object_instance.urlid, inbox=inbox)
class LDPViewSetGenerator(ModelViewSet):
"""An extension of ModelViewSet that generates automatically URLs for the model"""
model = None
......@@ -65,6 +324,10 @@ class LDPViewSetGenerator(ModelViewSet):
list_actions = {'get': 'list', 'post': 'create'}
detail_actions = {'get': 'retrieve', 'put': 'update', 'patch': 'partial_update', 'delete': 'destroy'}
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.lookup_field = LDPViewSetGenerator.get_lookup_arg(**kwargs)
@classonlymethod
def get_model(cls, **kwargs):
'''gets the model in the arguments or in the viewset definition'''
......@@ -75,7 +338,8 @@ class LDPViewSetGenerator(ModelViewSet):
@classonlymethod
def get_lookup_arg(cls, **kwargs):
return kwargs.get('lookup_url_kwarg') or cls.lookup_url_kwarg or kwargs.get('lookup_field') or cls.lookup_field
return kwargs.get('lookup_url_kwarg') or cls.lookup_url_kwarg or kwargs.get('lookup_field') or \
getattr(kwargs['model']._meta, 'lookup_field', 'pk') or cls.lookup_field
@classonlymethod
def get_detail_expr(cls, lookup_field=None, **kwargs):
......@@ -84,6 +348,15 @@ class LDPViewSetGenerator(ModelViewSet):
lookup_group = r'\d' if lookup_field == 'pk' else r'[\w\-\.]'
return r'(?P<{}>{}+)/'.format(lookup_field, lookup_group)
@classonlymethod
def build_nested_view_set(cls, view_set=None):
'''returns the the view_set parameter mixed into the LDPNestedViewSet class'''
if view_set is not None:
class LDPNestedCustomViewSet(LDPNestedViewSet, view_set):
pass
return LDPNestedCustomViewSet
return LDPNestedViewSet
@classonlymethod
def urls(cls, **kwargs):
'''constructs urls list for model passed in kwargs'''
......@@ -92,16 +365,45 @@ class LDPViewSetGenerator(ModelViewSet):
if kwargs.get('model_prefix'):
model_name = '{}-{}'.format(kwargs['model_prefix'], model_name)
detail_expr = cls.get_detail_expr(**kwargs)
# Gets permissions on the model if not explicitely passed to the view
if not 'permission_classes' in kwargs and hasattr(kwargs['model']._meta, 'permission_classes'):
kwargs['permission_classes'] = kwargs['model']._meta.permission_classes
urls = [
url('^$', cls.as_view(cls.list_actions, **kwargs), name='{}-list'.format(model_name)),
url('^' + detail_expr + '$', cls.as_view(cls.detail_actions, **kwargs),
name='{}-detail'.format(model_name)),
path('', cls.as_view(cls.list_actions, **kwargs), name='{}-list'.format(model_name)),
re_path('^' + detail_expr + '$', cls.as_view(cls.detail_actions, **kwargs),
name='{}-detail'.format(model_name)),
]
# append nested fields to the urls list
for field in kwargs.get('nested_fields') or cls.nested_fields:
urls.append(url('^' + detail_expr + field + '/', LDPNestedViewSet.nested_urls(field, **kwargs)))
for field_name in kwargs.get('nested_fields') or cls.nested_fields:
try:
nested_field = kwargs['model']._meta.get_field(field_name)
nested_model = nested_field.related_model
field_name_to_parent = nested_field.remote_field.name
except FieldDoesNotExist:
nested_model = getattr(kwargs['model'], field_name).field.model
nested_field = getattr(kwargs['model'], field_name).field.remote_field
field_name_to_parent = getattr(kwargs['model'], field_name).field.name
# urls should be called from _nested_ view set, which may need a custom view set mixed in
view_set = getattr(nested_model._meta, 'view_set', None)
nested_view_set = cls.build_nested_view_set(view_set)
urls.append(re_path('^' + detail_expr + field_name + '/',
nested_view_set.urls(
model=nested_model,
model_prefix=kwargs['model']._meta.object_name.lower(), # prefix with parent name
lookup_field=getattr(nested_model._meta, 'lookup_field', 'pk'),
exclude=(field_name_to_parent,) if nested_field.one_to_many else (),
permission_classes=getattr(nested_model._meta, 'permission_classes', []),
nested_field_name=field_name,
fields=getattr(nested_model._meta, 'serializer_fields', []),
nested_fields=[],
parent_model=kwargs['model'],
parent_lookup_field=cls.get_lookup_arg(**kwargs),
nested_field=nested_field,
field_name_to_parent=field_name_to_parent)))
return include(urls)
......@@ -114,51 +416,80 @@ class LDPViewSet(LDPViewSetGenerator):
renderer_classes = (JSONLDRenderer,)
parser_classes = (JSONLDParser,)
authentication_classes = (NoCSRFAuthentication,)
filter_backends = [SearchByQueryParamFilterBackend, LocalObjectOnContainerPathBackend]
prefetch_fields = None
def __init__(self, **kwargs):
super().__init__(**kwargs)
# attach filter backends based on permissions classes, to reduce the queryset based on these permissions
# https://www.django-rest-framework.org/api-guide/filtering/#generic-filtering
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.serializer_class = self.build_read_serializer()
self.write_serializer_class = self.build_write_serializer()
def build_read_serializer(self):
model_name = self.model._meta.object_name.lower()
lookup_field = get_resolver().reverse_dict[model_name + '-detail'][0][0][1][0]
meta_args = {'model': self.model, 'extra_kwargs': {
'@id': {'lookup_field': lookup_field}},
'depth': getattr(self, 'depth', Model.get_meta(self.model, 'depth', 0)),
'extra_fields': self.nested_fields}
return self.build_serializer(meta_args, 'Read')
def build_write_serializer(self):
self.filter_backends = type(self).filter_backends + list({perm_class().get_filter_backend(self.model)
for perm_class in self.permission_classes if hasattr(perm_class(), 'get_filter_backend')})
if None in self.filter_backends:
self.filter_backends.remove(None)
def filter_queryset(self, queryset):
if self.request.user.is_superuser:
return queryset
return super().filter_queryset(queryset)
def check_permissions(self, request):
if request.user.is_superuser:
return True
return super().check_permissions(request)
def check_object_permissions(self, request, obj):
if request.user.is_superuser:
return True
return super().check_object_permissions(request, obj)
def get_depth(self) -> int:
if getattr(self, 'force_depth', None):
#TODO: this exception on depth for writing should be handled by the serializer itself
return self.force_depth
if hasattr(self, 'request') and 'HTTP_DEPTH' in self.request.META:
return int(self.request.META['HTTP_DEPTH'])
if hasattr(self, 'depth'):
return self.depth
return getattr(self.model._meta, 'depth', 0)
def get_serializer_class(self):
model_name = self.model._meta.object_name.lower()
lookup_field = get_resolver().reverse_dict[model_name + '-detail'][0][0][1][0]
try:
lookup_field = get_resolver().reverse_dict[model_name + '-detail'][0][0][1][0]
except:
lookup_field = 'urlid'
meta_args = {'model': self.model, 'extra_kwargs': {
'@id': {'lookup_field': lookup_field}},
'depth': 10,
'extra_fields': self.nested_fields}
return self.build_serializer(meta_args, 'Write')
'@id': {'lookup_field': lookup_field}},
'depth': self.get_depth(),
'extra_fields': self.nested_fields}
def build_serializer(self, meta_args, name_prefix):
# create the Meta class to associate to LDPSerializer, using meta_args param
if self.fields:
meta_args['fields'] = self.fields
else:
meta_args['exclude'] = self.exclude or ()
meta_class = type('Meta', (), meta_args)
meta_args['exclude'] = self.exclude or getattr(self.model._meta, 'serializer_fields_exclude', ())
# create the Meta class to associate to LDPSerializer, using meta_args param
from djangoldp.serializers import LDPSerializer
return type(LDPSerializer)(self.model._meta.object_name.lower() + name_prefix + 'Serializer', (LDPSerializer,),
if self.serializer_class is None:
self.serializer_class = LDPSerializer
parent_meta = (self.serializer_class.Meta,) if hasattr(self.serializer_class, 'Meta') else ()
meta_class = type('Meta', parent_meta, meta_args)
return type(self.serializer_class)(self.model._meta.object_name.lower() + 'Serializer',
(self.serializer_class,),
{'Meta': meta_class})
# The chaining of filter through | may lead to duplicates and distinct should only be applied in the end.
def filter_queryset(self, queryset):
return super().filter_queryset(queryset).distinct()
def create(self, request, *args, **kwargs):
serializer = self.get_write_serializer(data=request.data)
self.force_depth = 10
serializer = self.get_serializer(data=request.data)
self.force_depth = None
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
......@@ -170,7 +501,9 @@ class LDPViewSet(LDPViewSetGenerator):
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object()
serializer = self.get_write_serializer(instance, data=request.data, partial=partial)
self.force_depth = 10
serializer = self.get_serializer(instance, data=request.data, partial=partial)
self.force_depth = None
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
......@@ -181,64 +514,35 @@ class LDPViewSet(LDPViewSetGenerator):
response_serializer = self.get_serializer()
data = response_serializer.to_representation(serializer.instance)
return Response(data)
def get_write_serializer(self, *args, **kwargs):
"""
Return the serializer instance that should be used for validating and
deserializing input, and for serializing output.
"""
serializer_class = self.get_write_serializer_class()
kwargs['context'] = self.get_serializer_context()
return serializer_class(*args, **kwargs)
def get_write_serializer_class(self):
"""
Return the class to use for the serializer.
Defaults to using `self.write_serializer_class`.
You may want to override this if you need to provide different
serializations depending on the incoming request.
(Eg. admins get full serialization, others get basic serialization)
"""
assert self.write_serializer_class is not None, (
"'%s' should either include a `write_serializer_class` attribute, "
"or override the `get_write_serializer_class()` method."
% self.__class__.__name__
)
return self.write_serializer_class
def perform_create(self, serializer, **kwargs):
if hasattr(self.model._meta, 'auto_author') and isinstance(self.request.user, get_user_model()):
kwargs[self.model._meta.auto_author] = self.request.user
serializer.save(**kwargs)
kwargs[self.model._meta.auto_author] = get_user_model().objects.get(pk=self.request.user.pk)
return serializer.save(**kwargs)
def get_queryset(self, *args, **kwargs):
if self.model:
return self.model.objects.all()
queryset = self.model.objects.all()
else:
return super(LDPView, self).get_queryset(*args, **kwargs)
queryset = super(LDPViewSet, self).get_queryset(*args, **kwargs)
if self.prefetch_fields is None:
self.prefetch_fields = get_prefetch_fields(self.model, self.get_serializer(), self.get_depth())
return queryset.prefetch_related(*self.prefetch_fields)
def dispatch(self, request, *args, **kwargs):
'''overriden dispatch method to append some custom headers'''
response = super(LDPViewSet, self).dispatch(request, *args, **kwargs)
response["Access-Control-Allow-Origin"] = request.META.get('HTTP_ORIGIN')
response["Access-Control-Allow-Methods"] = "GET,POST,PUT,PATCH,DELETE"
response["Access-Control-Allow-Headers"] = "authorization, Content-Type, if-match, accept"
response["Access-Control-Expose-Headers"] = "Location"
response["Access-Control-Allow-Credentials"] = 'true'
response["Accept-Post"] = "application/ld+json"
if response.status_code in [201, 200] and '@id' in response.data:
response["Location"] = response.data['@id']
response["Location"] = str(response.data['@id'])
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()
response['User'] = request.user.urlid
except AttributeError:
pass
return response
......@@ -251,51 +555,46 @@ class LDPNestedViewSet(LDPViewSet):
"""
parent_model = None
parent_lookup_field = None
related_field = None
nested_field = None
nested_related_name = None
nested_field_name = None
field_name_to_parent = None
def get_parent(self):
return get_object_or_404(self.parent_model, **{self.parent_lookup_field: self.kwargs[self.parent_lookup_field]})
def perform_create(self, serializer, **kwargs):
kwargs[self.nested_related_name] = self.get_parent()
kwargs[self.field_name_to_parent] = self.get_parent()
super().perform_create(serializer, **kwargs)
def get_queryset(self, *args, **kwargs):
if self.related_field.many_to_many or self.related_field.one_to_many:
return getattr(self.get_parent(), self.nested_field).all()
if self.related_field.many_to_one or self.related_field.one_to_one:
return [getattr(self.get_parent(), self.nested_field)]
related = getattr(self.get_parent(), self.nested_field_name)
if self.nested_field.many_to_many or self.nested_field.one_to_many:
if isinstance(self.nested_field, DynamicNestedField):
return related()
return related.all()
if self.nested_field.one_to_one or self.nested_field.many_to_one:
return type(related).objects.filter(pk=related.pk)
@classonlymethod
def get_related_fields(cls, model):
return {field.get_accessor_name(): field for field in model._meta.fields_map.values()}
@classonlymethod
def nested_urls(cls, nested_field, **kwargs):
try:
related_field = cls.get_model(**kwargs)._meta.get_field(nested_field)
except FieldDoesNotExist:
related_field = cls.get_related_fields(cls.get_model(**kwargs))[nested_field]
if related_field.related_query_name:
nested_related_name = related_field.related_query_name()
class LDPAPIView(APIView):
'''extends rest framework APIView to support Solid standards'''
authentication_classes = (NoCSRFAuthentication,)
def dispatch(self, request, *args, **kwargs):
'''overriden dispatch method to append some custom headers'''
response = super().dispatch(request, *args, **kwargs)
if response.status_code in [201, 200] and isinstance(response.data, dict) and '@id' in response.data:
response["Location"] = str(response.data['@id'])
else:
nested_related_name = related_field.remote_field.name
return cls.urls(
lookup_field=Model.get_meta(related_field.related_model, 'lookup_field', 'pk'),
model=related_field.related_model,
exclude=(nested_related_name,) if related_field.one_to_many else (),
parent_model=cls.get_model(**kwargs),
nested_field=nested_field,
nested_related_name=nested_related_name,
related_field=related_field,
parent_lookup_field=cls.get_lookup_arg(**kwargs),
model_prefix=cls.get_model(**kwargs)._meta.object_name.lower(),
permission_classes=Model.get_permission_classes(related_field.related_model,
kwargs.get('permission_classes', [LDPPermissions])),
lookup_url_kwarg=related_field.related_model._meta.object_name.lower() + '_id')
pass
if is_authenticated_user(request.user):
try:
response['User'] = request.user.urlid
except AttributeError:
pass
return response
class LDPSourceViewSet(LDPViewSet):
......@@ -325,3 +624,82 @@ class WebFingerView(View):
def post(self, request, *args, **kwargs):
return self.on_request(request)
def serve_static_content(request, path):
if request.method != "GET":
resolver = get_resolver()
match = resolver.resolve("/" + path)
request.user = AnonymousUser()
return match.func(request, *match.args, **match.kwargs)
server_url = getattr(settings, "BASE_URL", "http://localhost")
is_filtered = request.GET.get('search-fields', False)
output_dir = "ssr"
output_dir_filtered = "ssr_filtered"
if not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
if not os.path.exists(output_dir_filtered):
os.makedirs(output_dir_filtered, exist_ok=True)
file_path = os.path.join(output_dir if not is_filtered else output_dir_filtered, path[:-1])
if not file_path.endswith(".jsonld"):
file_path += ".jsonld"
if os.path.exists(file_path):
current_time = time.time()
file_mod_time = os.path.getmtime(file_path)
time_difference = current_time - file_mod_time
if time_difference > 24 * 60 * 60:
os.remove(file_path)
if not os.path.exists(file_path):
resolver = get_resolver()
match = resolver.resolve("/" + path)
request.user = AnonymousUser()
response = match.func(request, *match.args, **match.kwargs)
if response.status_code == 200:
directory = os.path.dirname(file_path)
if not os.path.exists(directory):
os.makedirs(directory)
json_content = JSONRenderer().render(response.data)
with open(file_path, "w", encoding="utf-8") as f:
f.write(
json_content.decode("utf-8")
.replace('"@id":"' + server_url, '"@id":"' + server_url + "/ssr")
.replace(
'"@id":"' + server_url + "/ssr/ssr",
'"@id":"' + server_url + "/ssr",
)[:-1]
+ ',"@context": "'
+ getattr(
settings,
"LDP_RDF_CONTEXT",
"https://cdn.startinblox.com/owl/context.jsonld",
)
+ '"}'
)
if os.path.exists(file_path):
with open(file_path, "r", encoding="utf-8") as file:
content = file.read()
try:
json_content = json.loads(content)
return JsonResponse(
json_content,
safe=False,
status=200,
content_type="application/ld+json",
headers={
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=3600",
},
)
except json.JSONDecodeError:
pass
return HttpResponseNotFound("File not found")
# djangoldp-crypto
Packages like [djangoldp](https://git.startinblox.com/djangoldp-packages/djangoldp) and [django-webidoidc-provider](https://git.startinblox.com/djangoldp-packages/django-webidoidc-provider) have some models and utilities which make use of cryptography. In general, we want to re-use that code in a supporting package to avoid duplication of effort. However, until it is more clear what ca be re-used, we are using this separate django app in this package. See [this ticket](https://git.startinblox.com/djangoldp-packages/djangoldp/issues/236) for more.
## Install
```bash
$ python -m pip install 'djangoldp[crypto]'
```
Tnen add the app to your `settings.yml` like so:
```yaml
INSTALLED_APPS:
- djangoldp_crypto
```
## Management commands
- `creatersakey`: Randomly generate a new RSA key for the DjangoLDP server
## Test
```bash
$ python -m unittest djangoldp_crypto.tests.runner
```
from django.contrib import admin
from djangoldp_crypto.models import RSAKey
@admin.register(RSAKey)
class RSAKeyAdmin(admin.ModelAdmin):
readonly_fields = ['kid', 'pub_key']
def save_model(self, request, obj, form, change):
obj.priv_key.replace('\r', '')
super().save_model(request, obj, form, change)
from Cryptodome.PublicKey import RSA
from django.core.management.base import BaseCommand
from djangoldp_crypto.models import RSAKey
class Command(BaseCommand):
help = 'Randomly generate a new RSA key for the DjangoLDP server'
def handle(self, *args, **options):
try:
key = RSA.generate(2048)
rsakey = RSAKey(priv_key=key.exportKey('PEM').decode('utf8'))
rsakey.save()
self.stdout.write('RSA key successfully created')
self.stdout.write(u'Private key: \n{0}'.format(rsakey.priv_key))
self.stdout.write(u'Public key: \n{0}'.format(rsakey.pub_key))
self.stdout.write(u'Key ID: \n{0}'.format(rsakey.kid))
except Exception as e:
self.stdout.write('Something goes wrong: {0}'.format(e))
# Generated by Django 2.2.19 on 2021-03-15 10:35
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='RSAKey',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('priv_key', models.TextField(help_text='Paste your private RSA Key here.', unique=True, verbose_name='Key')),
],
options={
'verbose_name': 'RSA Key',
'verbose_name_plural': 'RSA Keys',
},
),
]
from hashlib import md5
from Cryptodome.PublicKey import RSA
from django.db import models
from django.utils.translation import gettext_lazy as _
class RSAKey(models.Model):
priv_key = models.TextField(
verbose_name=_(u'Key'), unique=True,
help_text=_(u'Paste your private RSA Key here.'))
class Meta:
verbose_name = _(u'RSA Key')
verbose_name_plural = _(u'RSA Keys')
def __str__(self):
return u'{0}'.format(self.kid)
def __unicode__(self):
return self.__str__()
@property
def kid(self):
if not self.priv_key:
return ''
return u'{0}'.format(md5(self.priv_key.encode('utf-8')).hexdigest())
@property
def pub_key(self):
if not self.priv_key:
return ''
_pub_key = RSA.importKey(self.priv_key).publickey()
return _pub_key.export_key().decode('utf-8')
import sys
import django
import yaml
from django.conf import settings as django_settings
from djangoldp.conf.ldpsettings import LDPSettings
from djangoldp_crypto.tests.settings_default import yaml_config
# load test config
config = yaml.safe_load(yaml_config)
ldpsettings = LDPSettings(config)
django_settings.configure(ldpsettings)
django.setup()
from django.test.runner import DiscoverRunner
test_runner = DiscoverRunner(verbosity=1)
failures = test_runner.run_tests([
'djangoldp_crypto.tests.tests_rsakey',
])
if failures:
sys.exit(failures)
"""This module contains YAML configurations for djangoldp_crypto testing."""
yaml_config = """
dependencies:
ldppackages:
- djangoldp_crypto.tests
server:
INSTALLED_APPS:
- djangoldp_crypto
"""
from Cryptodome.PublicKey import RSA
from django.db import IntegrityError
from django.test import TestCase
from djangoldp_crypto.models import RSAKey
class TestRSAKey(TestCase):
def test_rsakey_unique(self):
priv_key = RSA.generate(2048)
RSAKey.objects.create(priv_key=priv_key)
with self.assertRaises(IntegrityError):
RSAKey.objects.create(priv_key=priv_key)
# Available commands
## generate_static_content
You can generate and make available at a /ssr/xxx URI a static copy of the AnonymousUser view of given models.
Those models need to be configured with the `static_version` and `static_params` Meta options like:
```python
class Location(Model):
name = models.CharField(max_length=255)
address = models.CharField(max_length=255)
zip_code = models.IntegerField()
visible = models.BooleanField(default=False)
class Meta:
# Allow generating a static version of the container view
static_version = 1
# Add some GET parameters to configure the selection of data
static_params = {
"search-fields": "visible",
"search-terms": True,
"search-method": "exact"
}
```
You will need additional settings defined either in your settings.yml or settings.py file:
```yml
BASE_URL: 'http://localhost:8000/'
MAX_RECURSION_DEPTH: 10 # Default value: 5
SSR_REQUEST_TIME: 20 # Default value 10 (seconds)
```
Then you can try it out by executing the following command:
```sh
python manage.py generate_static_content
```
You can also set a cron task or a celery Task to launch this command in a regular basis.
\ No newline at end of file
# User model requirements
When implementing authentication in your own application, you have two options:
* Using or extending [DjangoLDP-Account](https://git.startinblox.com/djangoldp-packages/djangoldp-account), a DjangoLDP package modelling federated users
* Using your own user model & defining the authentication behaviour yourself
Please see the [Authentication guide](https://git.startinblox.com/djangoldp-packages/djangoldp/wikis/guides/authentication) for full information
If you're going to use your own model then for federated login to work your user model must extend `DjangoLDP.Model`, or define a `urlid` field on the user model, for example:
```python
urlid = LDPUrlField(blank=True, null=True, unique=True)
```
If you don't include this field, then all users will be treated as users local to your instance
The `urlid` field is used to uniquely identify the user and is part of the Linked Data Protocol standard. For local users it can be generated at runtime, but for some resources which are from distant servers this is required to be stored
## Creating your first model
1. Create your django model inside a file myldpserver/myldpserver/models.py
Note that container_path will be use to resolve instance iri and container iri
In the future it could also be used to auto configure django router (e.g. urls.py)
```python
from djangoldp.models import Model
class Todo(Model):
name = models.CharField(max_length=255)
deadline = models.DateTimeField()
```
1.1. Configure container path (optional)
By default it will be "todos/" with an S for model called Todo
```python
<Model>._meta.container_path = "/my-path/"
```
1.2. Configure field visibility (optional)
Note that at this stage you can limit access to certain fields of models using
```python
<Model>._meta.serializer_fields (<>list of field names to show>)
```
For example, if you have a model with a related field with type **django.contrib.auth.models.User** you don't want to show personal details or password hashes.
E.g.
```python
from django.contrib.auth.models import User
User._meta.serializer_fields = ('username','first_name','last_name')
```
Note that this will be overridden if you explicitly set the fields= parameter as an argument to LDPViewSet.urls(), and filtered if you set the excludes= parameter.
2. Add a url in your urls.py:
```python
from django.conf.urls import url
from django.contrib import admin
from djangoldp.views import LDPViewSet
from .models import Todo
urlpatterns = [
url(r'^', include('djangoldp.urls')),
url(r'^admin/', admin.site.urls), # Optional
]
```
This creates 2 routes for each Model, one for the list, and one with an ID listing the detail of an object.
You could also only use this line in settings.py instead:
```python
ROOT_URLCONF = 'djangoldp.urls'
```
3. In the settings.py file, add your application name at the beginning of the application list, and add the following lines
```python
STATIC_ROOT = os.path.join(os.path.dirname(BASE_DIR), 'static')
LDP_RDF_CONTEXT = 'https://cdn.happy-dev.fr/owl/hdcontext.jsonld'
DJANGOLDP_PACKAGES = []
SITE_URL = 'http://localhost:8000'
BASE_URL = SITE_URL
```
* `LDP_RDF_CONTEXT` tells DjangoLDP where our RDF [ontology](https://www.w3.org/standards/semanticweb/ontology) is defined, which will be returned as part of our views in the 'context' field. This is a web URL and you can visit the value to view the full ontology online. The ontology can be a string, as in the example, but it can also be a dictionary, or a list of ontologies (see the [JSON-LD spec](https://json-ld.org) for examples)
* `DJANGOLDP_PACKAGES` defines which other [DjangoLDP packages](https://git.happy-dev.fr/startinblox/djangoldp-packages) we're using in this installation
* `SITE_URL` is the URL serving the site, e.g. `https://example.com/`. Note that if you include the DjangoLDP urls in a nested path (e.g. `https://example.com/api/`), then `SITE_URL` will need to be set to this value
* `BASE_URL` may be different from SITE_URL, e.g. `https://example.com/app/`
4. You can also register your model for the django administration site
```python
from django.contrib import admin
from djangoldp.admin import DjangoLDPAdmin
from .models import Todo
admin.site.register(Todo, DjangoLDPAdmin)
```
5. You then need to have your WSGI server pointing on myldpserver/myldpserver/wsgi.py
6. You will probably need to create a super user
```bash
$ ./manage.py createsuperuser
```
7. If you have no CSS on the admin screens :
```bash
$ ./manage.py collectstatic
```
## Execution
To start the server, `cd` to the root of your Django project and run :
```bash
$ python3 manage.py runserver
```
## Using DjangoLDP
### Models
To use DjangoLDP in your models you just need to extend djangoldp.Model
The Model class allows you to use your models in federation, adding a `urlid` field, and some key methods useful in federation
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 djangoldp.DjangoLDPAdmin so that the model is registered with [Django-Guardian object permissions](https://django-guardian.readthedocs.io/en/stable/userguide/admin-integration.html). An alternative version which extends Django's `UserAdmin` is available as djangoldp.DjangoLDPUserAdmin
#### Model Federation
Model `urlid`s can be **local** (matching `settings.SITE_URL`), or **external**
To maintain consistency between federated servers, [Activities](https://www.w3.org/TR/activitystreams-vocabulary) such as Create, Update, Delete are sent to external resources referenced in a ForeignKey relation, instructing them on how to manage the reverse-links with the local server
This behaviour can be disabled in settings.py
```python
SEND_BACKLINKS = False
```
It can also be disabled on a model instance
```python
instance.allow_create_backlinks = False
```
### LDPManager
DjangoLDP Models override `models.Manager`, accessible by `Model.objects`
#### local()
For situations where you don't want to include federated resources in a queryset e.g.
```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
## 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)
### Custom Parameters
#### lookup_field
Can be used to use a slug in the url instead of the primary key.
```python
LDPViewSet.urls(model=User, lookup_field='username')
```
#### 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.
In the following example, besides the urls `/members/` and `/members/<pk>/`, two others will be added to serve a container of the skills of the member: `/members/<pk>/skills/` and `/members/<pk>/skills/<pk>/`.
ForeignKey, ManyToManyField, OneToOneField that are not listed in the `nested_fields` option will be rendered as a flat list and will not have their own container endpoint.
```python
Meta:
nested_fields=["skills"]
```
Methods can be used to create custom read-only fields, by adding the name of the method in the `serializer_fields`. The same can be done for nested fields, but the method must be decorated with a `DynamicNestedField`.
```python
LDPUser.circles = lambda self: Circle.objects.filter(members__user=self)
LDPUser.circles.field = DynamicNestedField(Circle, 'circles')
```
### Configuring CSV export
DjangoLDP automaticallly provides CSV export on the admin site. By default, it exports the columns given in the `list_display` attribute. This can be overridden with the attribute `export_fields`. This setting can include fields of related object using `__`.
```python
class CustomAdmin(DjangoLDPAdmin):
export_fields = ['email', 'account__slug']
```
### Improving Performance
On certain endpoints, you may find that you only need a subset of fields on a model, and serializing them all is expensive (e.g. if I only need the `name` and `id` of each group chat, then why serialize all of their members?). To optimise the fields serialized, you can pass a custom header in the request, `Accept-Model-Fields`, with a `list` value of desired fields e.g. `['@id', 'name']`
### Searching on LDPViewSets
It's common to allow search parameters on our ViewSet fields. Djangoldp provides automated searching on fields via the query parameters of a request via the class `djangoldp.filters.SearchByQueryParamFilterBackend`, a FilterBackend applied by default to `LDPViewSet` and any subclasses which don't override the `filter_backends` property
To use this on a request, for example: `/circles/?search-fields=name,description&search-terms=test&search-method=ibasic&search-policy=union`. For detail:
* `search-fields`: a list of one or more fields to search on the model
* `search-terms`: the terms to search
* `search-method` (optional): the method to apply the search with (supports `basic` (contains), case-insensitive `ibasic` and `exact`)
* `search-policy` (optional): the policy to apply when merging the results from different fields searched (`union`, meaning include the union of all result sets. Or `intersection`, meaning include only the results matched against all fields)
Some databases might treat accented characters as different from non-accented characters (e.g. grève vs. greve). To avoid this behaviour, please follow the [Stackoverflow post](https://stackoverflow.com/questions/54071944/fielderror-unsupported-lookup-unaccent-for-charfield-or-join-on-the-field-not) here, and then add the setting `SEARCH_UNACCENT_EXTENSION = True` and make sure that `'django.contrib.postgres'` is in your `INSTALLED_APPS`.
## 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
Indicates the type the model corresponds to in the ontology. E.g. where `'hd:circle'` is defined in an ontology from `settings.LDP_RDF_CONTEXT`
```python
rdf_type = 'hd:circle'
```
### rdf_context
Sets added `context` fields to be serialized with model instances
```python
rdf_context = {'picture': 'foaf:depiction'}
```
### auto_author
This property allows to associate a model with the logged in user.
```python
class MyModel(models.Model):
author_user = models.ForeignKey(settings.AUTH_USER_MODEL)
class Meta:
auto_author = 'author_user'
```
Now when an instance of `MyModel` is saved, its `author_user` property will be set to the authenticated 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.
By default, no permission class is applied on your model, which means there will be no permission check. In other words, anyone will be able to run any kind of request, read and write, even without being authenticated. Superusers always have all permissions on all resources.
### Default Permission classes
DjangoLDP comes with a set of permission classes that you can use for standard behaviour.
* AuthenticatedOnly: Refuse access to anonymous users
* ReadOnly: Refuse access to any write request
* ReadAndCreate: Refuse access to any request changing an existing resource
* CreateOnly: Refuse access to any request other than creation
* AnonymousReadOnly: Refuse access to anonymous users with any write request
* LDDPermissions: Give access based on the permissions in the database. For container requests (list and create), based on model level permissions. For all others, based on object level permissions. This permission class is associated with a filter that only renders objects on which the user has access.
* PublicPermission: Give access based on a public flag on the object. This class must be used in conjonction with the Meta option `public_field`. This permission class is associated with a filter that only render objects that have the public flag set.
* OwnerPermissions: Give access based on the owner of the object. This class must be used in conjonction with the Meta option `owner_field` or `owner_urlid_field`. This permission class is associated with a filter that only render objects of which the user is owner. When using a reverse ForeignKey or M2M field with no related_name specified, do not add the '_set' suffix in the `owner_field`.
* OwnerCreatePermission: Refuse the creation of resources which owner is different from the request user.
* InheritPermissions: Give access based on the permissions on a related model. This class must be used in conjonction with the Meta option `inherit_permission`, which value must be a list of names of the `ForeignKey` or `OneToOneField` pointing to the objects bearing the permission classes. It also applies filter based on the related model. If several fields are given, at least one must give permission for the permission to be granted.
Permission classes can be chained together in a list, or through the | and & operators. Chaining in a list is equivalent to using the & operator.
```python
class MyModel(models.Model):
author_user = models.ForeignKey(settings.AUTH_USER_MODEL)
related = models.ForeignKey(SomeOtherModel)
class Meta:
permission_classes = [InheritPermissions, AuthenticatedOnly&(ReadOnly|OwnerPermissions|ACLPermissions)]
inherit_permissions = ['related']
owner_field = 'author_user'
```
### Role based permissions
Permissions can also be defind through roles defined in the Meta option `permission_roles`. When set, DjangoLDP will automatically create groups and assigne permissions on these groups when the object is created. The author can also be added automatically using the option `add_author`. The permission class `ACLPermissions` must be applied in order for the data base permission to be taken into account.
```python
class Circle(Model):
name = models.CharField(max_length=255, blank=True)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="owned_circles", on_delete=models.DO_NOTHING, null=True, blank=True)
members = models.ForeignKey(Group, related_name="circles", on_delete=models.SET_NULL, null=True, blank=True)
admins = models.ForeignKey(Group, related_name="admin_circles", on_delete=models.SET_NULL, null=True, blank=True)
class Meta(Model.Meta):
auto_author = 'owner'
permission_classes = [ACLPermissions]
permission_roles = {
'members': {'perms': ['view'], 'add_author': True},
'admins': {'perms': ['view', 'change', 'control'], 'add_author': True},
}
```
### Custom permission classes
Custom classes can be defined to handle specific permission checks. These class must inherit `djangoldp.permissions.LDPBasePermission` and can override the following method:
* get_filter_backend: returns a Filter class to be applied on the queryset before rendering. You can also define `filter_backend` as a field of the class directly.
* has_permission: called at the very begining of the request to check whether the user has permissions to call the specific HTTP method.
* has_object_permission: called on object requests on the first access to the object to check whether the user has rights on the request object.
* get_permissions: called on every single resource rendered to output the permissions of the user on that resource. This method should not access the database as it could severly affect performances.
### Inner permission rendering
For performance reasons, ACLs of resources inside a list are not rendered, which may require the client to request each single resource inside a list to get its ACLs. In some cases it's preferable to render these ACLs. This can be done using the setting `LDP_INCLUDE_INNER_PERMS`, setting its value to True.
## Other model options
### view_set
In case of custom viewset, you can use
```python
from djangoldp.models import Model
class Todo(Model):
name = models.CharField(max_length=255)
deadline = models.DateTimeField()
class Meta:
view_set = TodoViewSet
```
### serializer_fields
```python
from djangoldp.models import Model
class Todo(Model):
name = models.CharField(max_length=255)
deadline = models.DateTimeField()
class Meta:
serializer_fields = ['name']
```
Only `name` will be serialized
### serializer_fields_exclude
```python
from djangoldp.models import Model
class Todo(Model):
name = models.CharField(max_length=255)
deadline = models.DateTimeField()
class Meta:
serializer_fields_exclude = ['name']
```
Only `deadline` will be serialized
This is achieved when `LDPViewSet` sets the `exclude` in the serializer constructor. Note that if you use a custom viewset which does not extend LDPSerializer then you will need to set this property yourself.
### empty_containers
Slightly different from `serializer_fields` and `nested_fields` is the `empty_containers`, which allows for a list of nested containers which should be serialized, but without content, i.e. producing something like the following:
```
{ ..., 'members': {'@id': 'https://myserver.com/circles/x/members/'}, ... }
```
Where normally the serializer would output:
```
{ ..., 'members': {'@id': 'https://myserver.com/circles/x/members/',}, ... }
```
Note that this only applies when the field is nested in the serializer, i.e.:
* `https://myserver.com/circles/x/members/` **would not** serialize the container members
* `https://myserver.com/circles/x/members/` **would** serialize the container members
## Custom urls
To add customs urls who can not be add through the `Model` class, it's possible de create a file named `djangoldp_urls.py`. It will be executed like an `urls.py` file
## Pagination
To enable pagination feature just add this configuration to the server `settings.py` :
```python
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'djangoldp.pagination.LDPPagination',
'PAGE_SIZE': 20
}
```
## 301 on domain mismatch
To enable 301 redirection on domain mismatch, add `djangoldp.middleware.AllowOnlySiteUrl` in `MIDDLEWARE`
This ensures that your clients will use `SITE_URL` and avoid mismatch betwen url & the id of a resource/container
```python
MIDDLEWARE = [
'djangoldp.middleware.AllowOnlySiteUrl',
]
```
Notice that it will return only HTTP 200 Code.
[metadata]
name = djangoldp
version = attr: djangoldp.__version__
url = https://git.happy-dev.fr/happy-dev/djangoldp/
url = https://git.startinblox.com/djangoldp-packages/djangoldp/
author = Startin'blox
author_email = sylvain@happy-dev.fr
author_email = tech@startinblox.com
description = Linked Data Platform interface for Django Rest Framework
license = MIT
[wheel]
universal = 1
[options]
zip_safe = False
include_package_data = True
packages = find:
setup_requires =
django~=1.11
django~=4.2.0
install_requires =
django~=1.11
django_rest_framework
requests
validators~=0.12
pyld
django-guardian==2.0.0
django~=4.2.0
validators~=0.20.0
pyld~=1.0.0
django-guardian~=2.4.0
djangorestframework~=3.14.0
drf-spectacular~=0.24.0
requests~=2.31.0
pyyaml~=6.0.0
click~=8.1.0
django-brotli~=0.2.0
djangorestframework-guardian~=0.3.0
Faker~=14.2.0
[options.entry_points]
console_scripts =
djangoldp = djangoldp.cli:main
[options.extras_require]
dev =
validators
factory_boy>=2.11.0
factory_boy >= 2.11.0
crypto =
pycryptodomex~=3.10
[semantic_release]
version_source = tag
......