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
Commits on Source (546)
Showing
with 1014 additions and 563 deletions
dist dist
build
*.egg-info *.egg-info
*.eggs
*.pyc *.pyc
*~ *~
*.swp
djangoldp/tests/tests_temp.py
*/.idea/*
.DS_STORE
venv
--- ---
image: python:3.6 image: python:3.11
stages: include:
- test project: infra/gitlab
- release ref: master
file: templates/python.ci.yml
test: test:
stage: test stage: test
...@@ -11,22 +12,22 @@ test: ...@@ -11,22 +12,22 @@ test:
- pip install .[dev] - pip install .[dev]
- python -m unittest djangoldp.tests.runner - python -m unittest djangoldp.tests.runner
except: except:
- master
- tags - tags
tags: tags:
- test - test
publish: crypto-test:
stage: release stage: test
before_script:
- pip install python-semantic-release~=5.0 sib-commit-parser~=0.3
- git config user.name "${GITLAB_USER_NAME}"
- git config user.email "${GITLAB_USER_EMAIL}"
- git remote set-url origin "https://gitlab-ci-token:${GL_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
- git fetch --tags
script: script:
- semantic-release publish - pip install .[crypto]
only: - python -m unittest djangoldp_crypto.tests.runner
- master except:
- tags
tags: tags:
- deploy - test
publish:
cache: []
extends: .publish_pypi
# Changelog
<!--next-version-placeholder-->
## v3.0.5 (2023-07-24)
### Fix
* Readme ([`8792530`](https://github.com/djangoldp-packages/djangoldp/commit/87925305b2093282230511bceb38978db4b279a2))
## v3.0.4 (2023-07-24)
### Fix
* Readme ([`eeedc33`](https://github.com/djangoldp-packages/djangoldp/commit/eeedc3378ed3f4e454377431da6bb50202efdcdc))
## v3.0.3 (2023-07-24)
### Fix
* Readme ([`73597b6`](https://github.com/djangoldp-packages/djangoldp/commit/73597b65430a4d23306f78def0331bda60857493))
## Unreleased
* Imported CLI along with development template from sib-manager project
# use with your own settings.yml
FROM python:3.11
LABEL maintainer="Plup <plup@plup.io>"
# get server
RUN pip install djangoldp
# create a server instance
RUN djangoldp initserver ldpserver
WORKDIR /ldpserver
#COPY settings.yml .
RUN djangoldp install
RUN djangoldp configure --with-dummy-admin
# run the server
EXPOSE 8000
CMD ["djangoldp", "runserver"]
MIT License MIT License
Copyright (c) 2018 Startin blox Copyright (c) 2018-2023 Startin blox
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
......
graft djangoldp/conf/package_template
graft djangoldp/conf/server_template
graft djangoldp/templates/
This diff is collapsed.
from django.db.models import options from django.db.models import options
__version__ = '0.0.0' __version__ = '0.0.0'
options.DEFAULT_NAMES += ( options.DEFAULT_NAMES += (
'lookup_field', 'rdf_type', 'rdf_context', 'auto_author', 'auto_author_field', 'owner_field', 'view_set', 'lookup_field', 'rdf_type', 'rdf_context', 'auto_author', 'owner_field', 'owner_urlid_field',
'container_path', 'permission_classes', 'serializer_fields', 'serializer_fields_exclude', 'nested_fields', 'view_set', 'container_path', 'permission_classes', 'serializer_fields', 'serializer_fields_exclude', 'empty_containers',
'nested_fields_exclude', 'depth', 'anonymous_perms', 'authenticated_perms', 'owner_perms') 'nested_fields', 'depth', 'permission_roles', 'inherit_permissions', 'public_field', 'static_version', 'static_params', 'active_field', 'disable_url')
default_app_config = 'djangoldp.apps.DjangoldpConfig'
import threading import threading
import json import json
import time import time
import copy
import requests import requests
from queue import Queue from queue import Queue
from requests.exceptions import Timeout, ConnectionError from requests.exceptions import Timeout, ConnectionError
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
from urllib.parse import urlparse from urllib.parse import urlparse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db.models.signals import post_save, post_delete, m2m_changed from django.db.models.signals import post_save, post_delete, m2m_changed
from django.db.models import Q
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
from django.conf import settings from django.conf import settings
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
...@@ -31,13 +31,92 @@ SCHEDULER_SETTINGS = { ...@@ -31,13 +31,92 @@ SCHEDULER_SETTINGS = {
MAX_ACTIVITY_RESCHEDULES = getattr(settings, 'MAX_ACTIVITY_RESCHEDULES', 3) MAX_ACTIVITY_RESCHEDULES = getattr(settings, 'MAX_ACTIVITY_RESCHEDULES', 3)
DEFAULT_BACKOFF_FACTOR = getattr(settings, 'DEFAULT_BACKOFF_FACTOR', 1) DEFAULT_BACKOFF_FACTOR = getattr(settings, 'DEFAULT_BACKOFF_FACTOR', 1)
DEFAULT_ACTIVITY_DELAY = getattr(settings, 'DEFAULT_ACTIVITY_DELAY', 3) DEFAULT_ACTIVITY_DELAY = getattr(settings, 'DEFAULT_ACTIVITY_DELAY', 0.1)
DEFAULT_REQUEST_TIMEOUT = getattr(settings, 'DEFAULT_REQUEST_TIMEOUT', 10) DEFAULT_REQUEST_TIMEOUT = getattr(settings, 'DEFAULT_REQUEST_TIMEOUT', 10)
MAX_RECORDS_ACTIVITY_CACHE = getattr(settings, 'MAX_RECORDS_ACTIVITY_CACHE', 10000)
activity_sending_finished = Signal() activity_sending_finished = Signal()
class ActivityInMemoryCache:
'''
{
'urlid': {
# if an object is sent without a urlid it is not cached
'object_urlid': {
# for create/update, just interested in the most recent activity on this external id
'./': ACTIVITY
# for add/remove, we're interested also in the target (container_id)
'circles': ACTIVITY
'circle-members': ACTIVITY
}
}
}
'''
def __init__(self):
self.cache = {
}
def reset(self):
self.cache = {
}
def has(self, urlid, object_id, target_id=None):
if urlid not in self.cache or object_id not in self.cache[urlid]:
return False
if target_id is None:
target_id = './'
return target_id in self.cache[urlid][object_id]
def get(self, urlid, object_id, target_id=None):
if target_id is None:
target_id = './'
if self.has(urlid, object_id, target_id):
return self.cache[urlid][object_id][target_id]
else:
return None
def set(self, urlid, object_id, target_id=None, value=None):
if MAX_RECORDS_ACTIVITY_CACHE == 0:
return
if len(self.cache.keys()) > MAX_RECORDS_ACTIVITY_CACHE:
self.reset()
if target_id is None:
target_id = './'
if urlid not in self.cache:
self.cache[urlid] = {}
if object_id not in self.cache[urlid]:
self.cache[urlid][object_id] = {}
self.cache[urlid][object_id][target_id] = copy.deepcopy(value)
def invalidate(self, urlid, object_id=None, target_id=None):
# can clear the cache for an entire record or at any level in the cache
if object_id is not None:
if target_id is not None:
self.cache[urlid][object_id].pop(target_id, None)
else:
self.cache[urlid].pop(object_id, None)
else:
self.cache.pop(urlid, None)
# used to minimise the activity traffic to necessary activities,
# preferred over a database solution which is slower
ACTIVITY_CACHE = ActivityInMemoryCache()
ACTIVITY_SAVING_SETTING = getattr(settings, 'STORE_ACTIVITIES', 'ERROR')
class ActivityQueueService: class ActivityQueueService:
'''Manages an asynchronous queue for Activity format messages''' '''Manages an asynchronous queue for Activity format messages'''
initialized = False initialized = False
...@@ -96,6 +175,13 @@ class ActivityQueueService: ...@@ -96,6 +175,13 @@ class ActivityQueueService:
return {'data': {}} return {'data': {}}
return requests.post(url, data=json.dumps(activity), headers=headers, timeout=timeout) return requests.post(url, data=json.dumps(activity), headers=headers, timeout=timeout)
@classmethod
def _get_str_urlid(cls, obj):
if obj is None:
return None
return obj if isinstance(obj, str) else obj['@id'] if '@id' in obj else None
@classmethod @classmethod
def _save_activity_from_response(cls, response, url, scheduled_activity): def _save_activity_from_response(cls, response, url, scheduled_activity):
''' '''
...@@ -107,13 +193,27 @@ class ActivityQueueService: ...@@ -107,13 +193,27 @@ class ActivityQueueService:
if hasattr(response, 'text'): if hasattr(response, 'text'):
response_body = response.text response_body = response.text
response_location = getattr(response, "Location", None) response_location = getattr(response, "Location", None)
status_code = getattr(response, "status_code", None) status_code = getattr(response, "status_code", response['status_code'] if 'status_code' in response else None)
success = str(status_code).startswith("2") success = str(status_code).startswith("2") if status_code is not None else False
return cls._save_sent_activity(scheduled_activity.to_activitystream(), ActivityModel, success=success, # strip some key info from the activity (standardise the datatype)
external_id=url, type=scheduled_activity.type, activity_json = scheduled_activity.to_activitystream() if isinstance(scheduled_activity, ScheduledActivity) else scheduled_activity
response_location=response_location, response_code=str(status_code), activity_type = scheduled_activity.type if hasattr(scheduled_activity, 'type') else scheduled_activity['type']
response_body=response_body)
# set the activity cache
if 'object' in activity_json:
object_id = cls._get_str_urlid(activity_json['object'])
if object_id is not None:
target_origin = cls._get_str_urlid(activity_json.get('target', activity_json.get('origin', None)))
ACTIVITY_CACHE.set(url, object_id, target_origin, activity_json)
# save the activity if instructed to do so
if ACTIVITY_SAVING_SETTING == 'VERBOSE' or (not success and ACTIVITY_SAVING_SETTING == 'ERROR'):
return cls._save_sent_activity(activity_json, ActivityModel, success=success,
external_id=url, type=activity_type,
response_location=response_location, response_code=str(status_code),
response_body=response_body)
@classmethod @classmethod
def _attempt_failed_reschedule(cls, url, scheduled_activity, backoff_factor): def _attempt_failed_reschedule(cls, url, scheduled_activity, backoff_factor):
...@@ -179,7 +279,7 @@ class ActivityQueueService: ...@@ -179,7 +279,7 @@ class ActivityQueueService:
if type is None: if type is None:
return [] return []
type = type.lower() type = type.lower()
group_a = ['create', 'update', 'delete'] group_a = ['create', 'update', 'delete', 'creation', 'deletion']
groub_b = ['add', 'remove'] groub_b = ['add', 'remove']
if type in group_a: if type in group_a:
...@@ -241,22 +341,17 @@ class ActivityQueueService: ...@@ -241,22 +341,17 @@ class ActivityQueueService:
'''returns False if the two activities are equivalent''' '''returns False if the two activities are equivalent'''
return ordered(old_activity['object']) == ordered(new_activity['object']) return ordered(old_activity['object']) == ordered(new_activity['object'])
def get_most_recent_sent_activity(external_id): def get_most_recent_sent_activity(external_id, object_id):
'''returns the most recently sent activity which meets the specification''' return ACTIVITY_CACHE.get(external_id, object_id)
activities = ActivityModel.objects.filter(external_id=external_id, is_finished=True,
type__in=['create', 'update']).order_by('-created_at')[:10]
for a in activities.all():
a = a.to_activitystream()
if 'object' in a:
return a
return None
# str objects will have to be checked manually by the receiver # str objects will have to be checked manually by the receiver
new_activity = scheduled_activity.to_activitystream() new_activity = scheduled_activity.to_activitystream()
if 'object' not in new_activity or isinstance(new_activity['object'], str): if 'object' not in new_activity or isinstance(new_activity['object'], str) or \
'@id' not in new_activity['object']:
return True return True
old_activity = get_most_recent_sent_activity(url) old_activity = get_most_recent_sent_activity(url, new_activity['object']['@id'])
if old_activity is None: if old_activity is None:
return True return True
...@@ -269,33 +364,25 @@ class ActivityQueueService: ...@@ -269,33 +364,25 @@ class ActivityQueueService:
def _add_remove_is_new(cls, url, scheduled_activity): def _add_remove_is_new(cls, url, scheduled_activity):
'''auxiliary function validates if the receiver does not know about this Add/Remove activity''' '''auxiliary function validates if the receiver does not know about this Add/Remove activity'''
def get_most_recent_sent_activity(source_obj, source_target_origin): def get_most_recent_sent_activity(source_obj, source_target_origin):
# get a list of activities with the right type obj_id = cls._get_str_urlid(source_obj)
activities = ActivityModel.objects.filter(external_id=url, is_finished=True, target_id = cls._get_str_urlid(source_target_origin)
type__in=['add', 'remove']).order_by('-created_at')[:10]
# we are searching for the most recent Add/Remove activity which shares inbox, object and target/origin
for a in activities.all():
astream = a.to_activitystream()
obj = astream.get('object', None)
target_origin = astream.get('target', astream.get('origin', None))
if obj is None or target_origin is None:
continue
if source_obj == obj and source_target_origin == target_origin: return ACTIVITY_CACHE.get(url, obj_id, target_id)
return a
return None
new_activity = scheduled_activity.to_activitystream() new_activity = scheduled_activity.to_activitystream()
new_obj = new_activity.get('object', None) new_obj = new_activity.get('object', None)
new_target_origin = new_activity.get('target', new_activity.get('origin', None)) new_target_origin = new_activity.get('target', new_activity.get('origin', None))
# bounds checking # bounds checking
if new_obj is None or new_target_origin is None: if new_obj is None or new_target_origin is None or \
(not isinstance(new_obj, str) and '@id' not in new_obj) or \
(not isinstance(new_target_origin, str) and '@id' not in new_target_origin):
return True return True
# if most recent is the same type of activity as me, it's not new # if most recent is the same type of activity as me, it's not new
old_activity = get_most_recent_sent_activity(new_obj, new_target_origin) old_activity = get_most_recent_sent_activity(new_obj, new_target_origin)
if old_activity is not None and old_activity.type == scheduled_activity.type:
if old_activity is not None and old_activity['type'].lower() == scheduled_activity.type.lower():
return False return False
return True return True
...@@ -342,11 +429,11 @@ class ActivityQueueService: ...@@ -342,11 +429,11 @@ class ActivityQueueService:
response_location=None, response_code=None, local_id=None, response_body=None): response_location=None, response_code=None, local_id=None, response_body=None):
''' '''
Auxiliary function saves a record of parameterised activity Auxiliary function saves a record of parameterised activity
:param model_represenation: the model class which should be used to store the activity. Defaults to djangoldp.Activity, must be a subclass :param model_represenation: the model class which should be used to store the activity. Defaults to djangol.Activity, must be a subclass
''' '''
payload = bytes(json.dumps(activity), "utf-8") payload = json.dumps(activity)
if response_body is not None: if response_body is not None:
response_body = bytes(json.dumps(response_body), "utf-8") response_body = json.dumps(response_body)
if local_id is None: if local_id is None:
local_id = settings.SITE_URL + "/outbox/" local_id = settings.SITE_URL + "/outbox/"
if type is not None: if type is not None:
...@@ -372,7 +459,7 @@ class ActivityPubService(object): ...@@ -372,7 +459,7 @@ class ActivityPubService(object):
return return
obj = { obj = {
"@type": Model.get_model_rdf_type(model), "@type": getattr(model._meta, "rdf_type", None),
"@id": instance.urlid "@id": instance.urlid
} }
if obj['@type'] is None: if obj['@type'] is None:
...@@ -384,10 +471,13 @@ class ActivityPubService(object): ...@@ -384,10 +471,13 @@ class ActivityPubService(object):
value = getattr(instance, field_name, None) value = getattr(instance, field_name, None)
if value is None: if value is None:
continue continue
if not hasattr(value, 'urlid'):
continue
sub_object = { sub_object = {
"@id": value.urlid, "@id": value.urlid,
"@type": Model.get_model_rdf_type(type(value)) "@type": getattr(value._meta, "rdf_type", None)
} }
if sub_object['@type'] is None: if sub_object['@type'] is None:
...@@ -506,7 +596,7 @@ class ActivityPubService(object): ...@@ -506,7 +596,7 @@ class ActivityPubService(object):
info = model_meta.get_field_info(sender) info = model_meta.get_field_info(sender)
# bounds checking # bounds checking
if not hasattr(instance, 'urlid') or Model.get_model_rdf_type(sender) is None: if not hasattr(instance, 'urlid') or getattr(sender._meta, "rdf_type", None) is None:
return set() return set()
# check each foreign key for a distant resource # check each foreign key for a distant resource
...@@ -515,7 +605,7 @@ class ActivityPubService(object): ...@@ -515,7 +605,7 @@ class ActivityPubService(object):
if not relation_info.to_many: if not relation_info.to_many:
value = getattr(instance, field_name, None) value = getattr(instance, field_name, None)
if value is not None and Model.is_external(value): if value is not None and Model.is_external(value):
target_type = Model.get_model_rdf_type(type(value)) target_type = getattr(value._meta, "rdf_type", None)
if target_type is None: if target_type is None:
continue continue
...@@ -533,9 +623,9 @@ class ActivityPubService(object): ...@@ -533,9 +623,9 @@ class ActivityPubService(object):
return inboxes return inboxes
@classmethod @classmethod
def get_follower_inboxes(cls, object_urlid): def get_follower_inboxes(cls, object_urlid, object_container=None):
'''Auxiliary function returns a set of inboxes, from the followers of parameterised object urlid''' '''Auxiliary function returns a set of inboxes, from the followers of parameterised object urlid'''
inboxes = set(Follower.objects.filter(object=object_urlid).values_list('inbox', flat=True)) inboxes = set(Follower.objects.filter(Q(object=object_urlid) | Q(object__endswith=object_container)).values_list('inbox', flat=True))
return inboxes return inboxes
@classmethod @classmethod
...@@ -575,7 +665,7 @@ def check_save_for_backlinks(sender, instance, created, **kwargs): ...@@ -575,7 +665,7 @@ def check_save_for_backlinks(sender, instance, created, **kwargs):
and not Model.is_external(instance) \ and not Model.is_external(instance) \
and getattr(instance, 'username', None) != 'hubl-workaround-493': and getattr(instance, 'username', None) != 'hubl-workaround-493':
external_urlids = ActivityPubService.get_related_externals(sender, instance) external_urlids = ActivityPubService.get_related_externals(sender, instance)
inboxes = ActivityPubService.get_follower_inboxes(instance.urlid) inboxes = ActivityPubService.get_follower_inboxes(instance.urlid, instance.get_container_path())
targets = set().union(ActivityPubService.get_target_inboxes(external_urlids), inboxes) targets = set().union(ActivityPubService.get_target_inboxes(external_urlids), inboxes)
if len(targets) > 0: if len(targets) > 0:
...@@ -598,13 +688,13 @@ def check_save_for_backlinks(sender, instance, created, **kwargs): ...@@ -598,13 +688,13 @@ def check_save_for_backlinks(sender, instance, created, **kwargs):
def check_delete_for_backlinks(sender, instance, **kwargs): def check_delete_for_backlinks(sender, instance, **kwargs):
if getattr(settings, 'SEND_BACKLINKS', True) and getattr(instance, 'allow_create_backlink', False) \ if getattr(settings, 'SEND_BACKLINKS', True) and getattr(instance, 'allow_create_backlink', False) \
and getattr(instance, 'username', None) != 'hubl-workaround-493': and getattr(instance, 'username', None) != 'hubl-workaround-493':
targets = ActivityPubService.get_follower_inboxes(instance.urlid) targets = ActivityPubService.get_follower_inboxes(instance.urlid, instance.get_container_path())
if len(targets) > 0: if len(targets) > 0:
for target in targets: for target in targets:
ActivityPubService.send_delete_activity(BACKLINKS_ACTOR, { ActivityPubService.send_delete_activity(BACKLINKS_ACTOR, {
"@id": instance.urlid, "@id": instance.urlid,
"@type": Model.get_model_rdf_type(sender) "@type": getattr(instance._meta, "rdf_type", None)
}, target) }, target)
# remove any Followers on this resource # remove any Followers on this resource
...@@ -640,8 +730,8 @@ def check_m2m_for_backlinks(sender, instance, action, *args, **kwargs): ...@@ -640,8 +730,8 @@ def check_m2m_for_backlinks(sender, instance, action, *args, **kwargs):
# we can only send backlinks on pre_clear because on post_clear the objects are gone # we can only send backlinks on pre_clear because on post_clear the objects are gone
if action != "pre_clear" and pk_set is None: if action != "pre_clear" and pk_set is None:
return return
member_rdf_type = Model.get_model_rdf_type(member_model) member_rdf_type = getattr(member_model._meta, "rdf_type", None)
container_rdf_type = Model.get_model_rdf_type(type(instance)) container_rdf_type = getattr(instance._meta, "rdf_type", None)
if member_rdf_type is None: if member_rdf_type is None:
return return
...@@ -650,7 +740,12 @@ def check_m2m_for_backlinks(sender, instance, action, *args, **kwargs): ...@@ -650,7 +740,12 @@ def check_m2m_for_backlinks(sender, instance, action, *args, **kwargs):
# build list of targets (models affected by the change) # build list of targets (models affected by the change)
if action == "pre_clear": if action == "pre_clear":
pk_set = sender.objects.all().values_list(member_model.__name__.lower(), flat=True) sender_info = model_meta.get_field_info(sender)
for field_name, relation_info in sender_info.relations.items():
if relation_info.related_model == member_model:
pk_set = sender.objects.all().values_list(field_name, flat=True)
if pk_set is None or len(pk_set) == 0:
return
query_set = member_model.objects.filter(pk__in=pk_set) query_set = member_model.objects.filter(pk__in=pk_set)
targets = build_targets(query_set) targets = build_targets(query_set)
......
...@@ -33,12 +33,24 @@ class Activity(Object): ...@@ -33,12 +33,24 @@ class Activity(Object):
delattr(new, "bcc") delattr(new, "bcc")
return new return new
def _validate_type_id_defined(self, value):
'''recursively ensures that all nested dict items define @id and @type attributes'''
for item in value.items():
if isinstance(item[1], dict):
item_value = item[1]
if '@type' not in item_value or '@id' not in item_value:
raise errors.ActivityStreamValidationError("all sub-objects passed in activity object must define @id and @type tags")
self._validate_type_id_defined(item_value)
def validate(self): def validate(self):
for attr in self.required_attributes.keys(): for attr in self.required_attributes.keys():
if not isinstance(getattr(self, attr, None), self.required_attributes[attr]): if not isinstance(getattr(self, attr, None), self.required_attributes[attr]):
raise errors.ActivityStreamValidationError("required attribute " + attr + " of type " raise errors.ActivityStreamValidationError("required attribute " + attr + " of type "
+ str(self.required_attributes[attr])) + str(self.required_attributes[attr]))
# validate that every dictionary stored in object has @id and @type
self._validate_type_id_defined(self.__getattribute__("object"))
class Add(Activity): class Add(Activity):
type = "Add" type = "Add"
......
from csv import DictWriter
from django.contrib import admin from django.contrib import admin
from guardian.admin import GuardedModelAdmin
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from djangoldp.models import Activity, ScheduledActivity from django.core.exceptions import FieldDoesNotExist
from django.http import HttpResponse
from guardian.admin import GuardedModelAdmin
from djangoldp.models import Activity, ScheduledActivity, Follower
from djangoldp.activities.services import ActivityQueueService
class DjangoLDPAdmin(GuardedModelAdmin): class DjangoLDPAdmin(GuardedModelAdmin):
...@@ -9,13 +13,38 @@ class DjangoLDPAdmin(GuardedModelAdmin): ...@@ -9,13 +13,38 @@ class DjangoLDPAdmin(GuardedModelAdmin):
An admin model representing a federated object. Inherits from GuardedModelAdmin to provide Django-Guardian An admin model representing a federated object. Inherits from GuardedModelAdmin to provide Django-Guardian
object-level permissions object-level permissions
''' '''
pass actions = ['export_csv']
export_fields = []
class DjangoLDPUserAdmin(UserAdmin, GuardedModelAdmin): def resolve_verbose_name(self, field_path):
field = self
for field_name in field_path.split('__'):
try:
field = field.model._meta.get_field(field_name)
except FieldDoesNotExist:
return None
return field.verbose_name
@admin.action(description="Export CSV")
def export_csv(self, request, queryset):
response = HttpResponse(content_type="text/csv")
response['Content-Disposition'] = f'attachment; filename="{self.model.__name__}.csv"'
# only keep fields that can be resolved, keep only urlid if none
field_list = list(filter(self.resolve_verbose_name, self.export_fields or self.list_display)) or ['urlid']
headers = {field:self.resolve_verbose_name(field) for field in field_list}
writer = DictWriter(response, fieldnames=field_list)
writer.writerow(headers)
writer.writerows(queryset.values(*field_list))
return response
class DjangoLDPUserAdmin(UserAdmin, DjangoLDPAdmin):
'''An extension of UserAdmin providing the functionality of DjangoLDPAdmin''' '''An extension of UserAdmin providing the functionality of DjangoLDPAdmin'''
list_display = ('urlid', 'email', 'first_name', 'last_name', 'date_joined', 'last_login', 'is_staff') list_display = ('urlid', 'email', 'first_name', 'last_name', 'date_joined', 'last_login', 'is_staff')
search_fields = ['urlid', 'email', 'first_name', 'last_name']
ordering = ['urlid']
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj) fieldsets = super().get_fieldsets(request, obj)
...@@ -35,11 +64,21 @@ class DjangoLDPUserAdmin(UserAdmin, GuardedModelAdmin): ...@@ -35,11 +64,21 @@ class DjangoLDPUserAdmin(UserAdmin, GuardedModelAdmin):
return fieldsets return fieldsets
@admin.action(description='Resend activity')
def resend_activity(modeladmin, request, queryset):
for a in queryset:
ActivityQueueService.send_activity(a.external_id, a.to_activitystream())
resend_activity.short_description = 'Resend activity'
@admin.register(Activity, ScheduledActivity)
class ActivityAdmin(DjangoLDPAdmin): class ActivityAdmin(DjangoLDPAdmin):
fields = ['urlid', 'type', 'local_id', 'external_id', 'created_at', 'success', 'payload_view', 'response_code', fields = ['urlid', 'type', 'local_id', 'external_id', 'created_at', 'success', 'payload_view', 'response_code',
'response_location', 'response_body_view'] 'response_location', 'response_body_view']
list_display = ['created_at', 'type', 'local_id', 'external_id', 'success', 'response_code'] list_display = ['created_at', 'type', 'local_id', 'external_id', 'success', 'response_code']
readonly_fields = ['created_at', 'payload_view', 'response_location', 'response_code', 'response_body_view'] readonly_fields = ['created_at', 'payload_view', 'response_location', 'response_code', 'response_body_view']
search_fields = ['urlid', 'type', 'local_id', 'external_id', 'response_code']
actions = [resend_activity]
def payload_view(self, obj): def payload_view(self, obj):
return str(obj.to_activitystream()) return str(obj.to_activitystream())
...@@ -48,5 +87,10 @@ class ActivityAdmin(DjangoLDPAdmin): ...@@ -48,5 +87,10 @@ class ActivityAdmin(DjangoLDPAdmin):
return str(obj.response_to_json()) return str(obj.response_to_json())
admin.site.register(Activity, ActivityAdmin) @admin.register(Follower)
admin.site.register(ScheduledActivity, ActivityAdmin) class FollowerAdmin(DjangoLDPAdmin):
fields = ['urlid', 'object', 'inbox', 'follower']
list_display = ['urlid', 'object', 'inbox', 'follower']
search_fields = ['object', 'inbox', 'follower']
...@@ -7,6 +7,18 @@ class DjangoldpConfig(AppConfig): ...@@ -7,6 +7,18 @@ class DjangoldpConfig(AppConfig):
def ready(self): def ready(self):
self.auto_register_model_admin() self.auto_register_model_admin()
self.start_activity_queue() self.start_activity_queue()
# Patch guardian core to avoid prefetching permissions several times
from guardian.core import ObjectPermissionChecker
ObjectPermissionChecker._prefetch_cache_orig = ObjectPermissionChecker._prefetch_cache
def _prefetch_cache(self):
if hasattr(self.user, "_guardian_perms_cache"):
self._obj_perms_cache = self.user._guardian_perms_cache
return
self._prefetch_cache_orig()
ObjectPermissionChecker._prefetch_cache = _prefetch_cache
def start_activity_queue(self): def start_activity_queue(self):
from djangoldp.activities.services import ActivityQueueService from djangoldp.activities.services import ActivityQueueService
...@@ -22,6 +34,7 @@ class DjangoldpConfig(AppConfig): ...@@ -22,6 +34,7 @@ class DjangoldpConfig(AppConfig):
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from djangoldp.admin import DjangoLDPAdmin from djangoldp.admin import DjangoLDPAdmin
from djangoldp.urls import get_all_non_abstract_subclasses
from djangoldp.models import Model from djangoldp.models import Model
for package in settings.DJANGOLDP_PACKAGES: for package in settings.DJANGOLDP_PACKAGES:
...@@ -36,9 +49,6 @@ class DjangoldpConfig(AppConfig): ...@@ -36,9 +49,6 @@ class DjangoldpConfig(AppConfig):
except ModuleNotFoundError: except ModuleNotFoundError:
pass pass
model_classes = {cls.__name__: cls for cls in Model.__subclasses__()} for model in get_all_non_abstract_subclasses(Model):
if not admin.site.is_registered(model):
for class_name in model_classes: admin.site.register(model, DjangoLDPAdmin)
model_class = model_classes[class_name]
if not admin.site.is_registered(model_class):
admin.site.register(model_class, DjangoLDPAdmin)
'''
DjangoLDP Check Integrity
Usage `./manage.py check_integrity --help`
'''
from django.apps import apps
from django.conf import settings
from djangoldp.models import LDPSource
from urllib.parse import urlparse
import requests
# Helper command for argument type checking
def is_string(target):
if isinstance(target, str):
return target
return False
# Helper command to check status code of a target
def is_alive(target, status_code = 200):
return requests.get(target).status_code == status_code
# Add argument to the `check_integrity` command
def add_arguments(parser):
parser.add_argument(
"--ignore",
action="store",
default=False,
type=is_string,
help="Ignore any server, comma separated",
)
parser.add_argument(
"--ignore-faulted",
default=False,
nargs="?",
const=True,
help="Ignore eventual faulted",
)
parser.add_argument(
"--ignore-404",
default=False,
nargs="?",
const=True,
help="Ignore eventual 404",
)
parser.add_argument(
"--fix-faulted-resources",
default=False,
nargs="?",
const=True,
help="Fix faulted resources",
)
parser.add_argument(
"--fix-404-resources",
default=False,
nargs="?",
const=True,
help="Fix 404 resources",
)
parser.add_argument(
"--fix-offline-servers",
default=False,
nargs="?",
const=True,
help="Remove resources from offline servers",
)
# Define our own checks
def check_integrity(options):
models = apps.get_models()
ignored = set()
if(options["ignore"]):
for target in options["ignore"].split(","):
ignored.add(urlparse(target).netloc)
if(len(ignored) > 0):
print("Ignoring servers:")
for server in ignored:
print("- "+server)
resources = set()
resources_map = dict()
base_urls = set()
for model in models:
for obj in model.objects.all():
if hasattr(obj, "urlid"):
if(obj.urlid):
if(not obj.urlid.startswith(settings.BASE_URL)):
url = urlparse(obj.urlid).netloc
if(url not in ignored):
resources.add(obj.urlid)
resources_map[obj.urlid] = obj
base_urls.add(url)
if(len(base_urls) > 0):
print("Found "+str(len(resources_map))+" distant resources on "+str(len(models))+" models")
print("Servers that I have backlinks to:")
for server in base_urls:
print("- "+server)
else:
print("I don't have any backlink")
source_urls = set()
for source in LDPSource.objects.all():
source_urls.add(urlparse(source.urlid).netloc)
if(len(source_urls) > 0):
print("Servers that I'm allowed to get federated to:")
for server in source_urls:
print("- "+server)
else:
print("I'm not federated")
difference_servers = base_urls.difference(source_urls)
if(len(difference_servers) > 0):
print("Servers that I should not get aware of:")
for server in difference_servers:
print("- "+server)
# Handle faulted resources
if(not options["ignore_faulted"]):
faulted_resources = set()
for server in difference_servers:
for resource in resources:
if(urlparse(resource).netloc in server):
faulted_resources.add(resource)
if(len(faulted_resources) > 0):
print("Resources in fault:")
for resource in faulted_resources:
print("- "+resource)
else:
print("No resource are in fault")
if(options["fix_faulted_resources"]):
for resource in faulted_resources:
try:
resources_map[resource].delete()
except:
pass
print("Fixed faulted resources")
else:
print("Fix them with `./manage.py check_integrity --fix-faulted-resources`")
else:
print("I accept datas for every of those servers")
# Handle 404 resources
if(not options["ignore_404"]):
resources_404 = set()
resources_servers_offline = set()
for resource in resources:
try:
if(is_alive(resource, 404)):
resources_404.add(resource)
except:
resources_servers_offline.add(resource)
if(len(resources_404) > 0):
print("Faulted resources, 404:")
for resource in resources_404:
print("- "+resource)
if(options["fix_404_resources"]):
for resource in resources_404:
try:
resources_map[resource].delete()
except:
pass
print("Fixed 404 resources")
else:
print("Fix them with `./manage.py check_integrity --fix-404-resources`")
if(len(resources_servers_offline) > 0):
print("Faulted resources, servers offline:")
for resource in resources_servers_offline:
print("- "+resource)
if(options["fix_offline_servers"]):
for resource in resources_servers_offline:
try:
resources_map[resource].delete()
except:
pass
print("Fixed resources on offline servers")
else:
print("Fix them with `./manage.py check_integrity --fix-offline-servers`")
else:
print("No 404 in known resources")
\ No newline at end of file
import click
import sys
import yaml
import subprocess
from pkg_resources import resource_filename
from pathlib import Path
from django.core import management
from django.core.management.base import CommandError
from . import __version__
# click entrypoint
@click.group()
@click.version_option(__version__)
def main():
"""DjangoLDP CLI"""
@main.command()
@click.argument('name', nargs=1, required=False)
def initserver(name):
"""Start a DjangoLDP server."""
try:
# use directly pwd
directory = Path.cwd()
if name:
# create a directory from project name in pwd
directory = Path.cwd() / name
directory.mkdir(parents=False, exist_ok=False)
# get the template path
template = resource_filename(__name__, 'conf/server_template')
# wrap the default django-admin startproject command
# this call import django settings and configure it
# see: https://docs.djangoproject.com/fr/2.2/topics/settings/#calling-django-setup-is-required-for-standalone-django-usage
# see: https://github.com/django/django/blob/stable/2.2.x/django/core/management/templates.py#L108
# fix: in 2.2 gabarit files options has been renamed: https://github.com/django/django/blob/stable/2.2.x/django/core/management/templates.py#L53
management.call_command('startproject', name, directory, template=template, files=['settings.yml'])
except FileExistsError:
click.echo(f'Error: the folder {directory} already exists')
sys.exit(1)
except CommandError as e:
click.echo(f'Error: {e}')
directory.rmdir()
sys.exit(1)
@main.command()
@click.argument('name', nargs=1)
def startpackage(name):
"""Start a DjangoLDP package."""
try:
# set directory
directory = Path.cwd() / name
# get the template path
template = resource_filename(__name__, 'conf/package_template')
# create dir
directory.mkdir(parents=False, exist_ok=False)
# wrap the default startapp command
management.call_command('startapp', name, directory, template=template)
except FileExistsError:
click.echo(f'Error: the folder {directory} already exists')
sys.exit(1)
except CommandError as e:
click.echo(f'Error: {e}')
sys.exit(1)
@main.command()
def install():
"""Install project dependencies."""
try:
# load dependencies from config file
path = Path.cwd() / 'settings.yml'
with open(path, 'r') as f:
dependencies = yaml.safe_load(f).get('dependencies', [])
# install them by calling pip command
cmd = [sys.executable, "-m", "pip", "install", "--upgrade"]
try:
cmd.extend(dependencies)
subprocess.run(cmd).check_returncode()
click.echo('Installation done!')
except TypeError:
click.echo('No dependency to install')
except FileNotFoundError:
click.echo('Config error: no settings.yml file in this directory')
sys.exit(1)
except subprocess.CalledProcessError as e:
click.echo(f'Installation error: {e}')
sys.exit(1)
@main.command()
@click.option('--with-admin', 'admin', help='Create an administrator user with email.')
@click.option('--email', help='Provide an email for administrator.')
@click.option('--with-dummy-admin', 'dummy_admin', is_flag=True, help='Create a default "admin" user.')
def configure(admin, dummy_admin, email):
"""Configure the project."""
try:
# shortcut to the djangoldp.management command
path = str(Path.cwd() / 'manage.py')
cmd = [sys.executable, path, 'configure']
if admin:
if not email:
click.echo('Error: missing email for admin user')
return
cmd.extend(['--with-admin', admin, '--email', email])
elif dummy_admin:
cmd.append('--with-dummy-admin')
subprocess.run(cmd).check_returncode()
click.echo('Configuration done!')
except subprocess.CalledProcessError as e:
click.echo(f'Configuration error: {e}')
sys.exit(1)
@main.command()
def runserver():
"""Run the Django embeded webserver."""
try:
# shortcut to the djangoldp.management command
path = str(Path.cwd() / 'manage.py')
cmd = [sys.executable, path, 'runserver', '0.0.0.0:8000']
subprocess.run(cmd).check_returncode()
except subprocess.CalledProcessError as e:
click.echo(f'Execution error: {e}')
sys.exit(1)
"""This module override some of default django configuration from the global settings."""
from django.conf.global_settings import *
####################
# LDP #
####################
LDP_RDF_CONTEXT = 'https://cdn.startinblox.com/owl/context.jsonld'
MAX_ACTIVITY_RESCHEDULES = 3
DEFAULT_BACKOFF_FACTOR = 1
DEFAULT_ACTIVITY_DELAY = 0.2
DEFAULT_REQUEST_TIMEOUT = 10
LDP_INCLUDE_INNER_PERMS = False
####################
# CORE #
####################
# https://en.wikipedia.org/wiki/List_of_tz_zones_by_name (although not all
# systems may support all possibilities). When USE_TZ is True, this is
# interpreted as the default user time zone.
TIME_ZONE = 'UTC'
# If you set this to True, Django will use timezone-aware datetimes.
USE_TZ = True
# Language code for this installation. All choices can be found here:
# http://www.i18nguy.com/unicode/language-identifiers.html
LANGUAGE_CODE = 'en-us'
# Database connection info. If left empty, will default to the dummy backend.
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'db.sqlite3',
}
}
# List of strings representing installed apps.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'djangoldp',
'guardian'
]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
# Default server url
SITE_URL = 'http://localhost:8000'
BASE_URL = SITE_URL
# Default URL conf
ROOT_URLCONF = 'djangoldp.urls'
# Absolute filesystem path to the directory that will hold user-uploaded files.
# Example: "/var/www/example.com/media/"
MEDIA_ROOT = ''
# URL that handles the media served from MEDIA_ROOT.
# Examples: "http://example.com/media/", "http://media.example.com/"
MEDIA_URL = '/media/'
# Absolute path to the directory static files should be collected to.
# Example: "/var/www/example.com/static/"
STATIC_ROOT = None
# URL that handles the static files served from STATIC_ROOT.
# Example: "http://example.com/static/", "http://static.example.com/"
STATIC_URL = '/static/'
# ETAGS config
USE_ETAGS = True
# Default X-Frame-Options header value
X_FRAME_OPTIONS = 'DENY'
# The Python dotted path to the WSGI application that Django's internal server
# (runserver) will use. If `None`, the return value of
# 'django.core.wsgi.get_wsgi_application' is used, thus preserving the same
# behavior as previous versions of Django. Otherwise this should point to an
# actual WSGI application object.
WSGI_APPLICATION = 'server.wsgi.application'
##############
# MIDDLEWARE #
##############
# List of middleware to use. Order is important; in the request phase, these
# middleware will be applied in the order given, and in the response
# phase the middleware will be applied in reverse order.
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'djangoldp.middleware.AllowRequestedCORSMiddleware',
'django.middleware.gzip.GZipMiddleware',
'django_brotli.middleware.BrotliMiddleware'
]
##################
# REST FRAMEWORK #
##################
REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_PAGINATION_CLASS': 'djangoldp.pagination.LDPPagination'
}
###################
# DRF SPECTACULAR #
###################
ENABLE_SWAGGER_DOCUMENTATION = False
SPECTACULAR_SETTINGS = {
'TITLE': 'DjangoLDP Based API Description',
'DESCRIPTION': 'Here you will find the list of all endpoints available on your Djangoldp-based server instance and\
the available methods, needed parameters and requests examples. The list of available endpoints depend on your instance configuration\
especially the list of bloxes and associated django models activated.',
'VERSION': '2.1.37',
'SERVE_INCLUDE_SCHEMA': False
}
############
# SESSIONS #
############
# Same site policy
DCS_SESSION_COOKIE_SAMESITE = 'none'
##################
# AUTHENTICATION #
##################
AUTHENTICATION_BACKENDS = ['django.contrib.auth.backends.ModelBackend', 'guardian.backends.ObjectPermissionBackend']
OIDC_ACCESS_CONTROL_ALLOW_HEADERS = 'Content-Type, if-match, accept, authorization, DPoP, cache-control, pragma, prefer'
# The minimum number of seconds a password reset link is valid for
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 * 3
DISABLE_LOCAL_OBJECT_FILTER = False
GUARDIAN_AUTO_PREFETCH = True
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
import os
import sys
import yaml
import logging
from django.core.exceptions import ImproperlyConfigured
from django.conf import settings as django_settings
from pathlib import Path
from collections import OrderedDict
from typing import Iterable
from importlib import import_module
from . import default_settings
logger = logging.getLogger(__name__)
def configure(filename='settings.yml'):
"""Helper function to configure django from LDPSettings."""
yaml_config = None
try:
with open(filename, 'r') as f:
yaml_config = yaml.safe_load(f)
except FileNotFoundError:
logger.info('Starting project without configuration file')
# ref: https://docs.djangoproject.com/fr/2.2/topics/settings/#custom-default-settings
ldpsettings = LDPSettings(yaml_config)
django_settings.configure(ldpsettings)
class LDPSettings(object):
"""Class managing the DjangoLDP configuration."""
def __init__(self, config):
"""Build a Django Setting object from a dict."""
if django_settings.configured:
raise ImproperlyConfigured('Settings have been configured already')
self._config = config
self._explicit_settings = set(default_settings.__dict__.keys()) #default settings are explicit
self._settings = self.build_settings()
def build_settings(self, extend=['INSTALLED_APPS', 'MIDDLEWARE']):
"""
Look for the parameters in multiple places.
Each step overrides the value of the previous key found. Except for "extend" list. Those value must be lists and all values found are added to these lists without managing duplications.
Resolution order of the configuration:
1. Core default settings
2. Packages settings
3. Code from a local settings.py file
4. YAML config file
"""
# helper loop
def update_with(config):
for setting, value in config.items():
self._explicit_settings.add(setting)
if setting in extend:
settings[setting].extend(value)
elif not setting.startswith('_'):
settings.update({setting: value})
# start from default core settings
settings = default_settings.__dict__.copy()
logger.debug(f'Building settings from core defaults')
# INSTALLED_APPS starts empty
settings['INSTALLED_APPS'] = []
# look settings from packages in the order they are given (local overrides installed)
for pkg in self.DJANGOLDP_PACKAGES:
# FIXME: There is something better to do here with the sys.modules path
try:
# override with values from installed package
mod = import_module(f'{pkg}.djangoldp_settings')
update_with(mod.__dict__)
logger.debug(f'Updating settings from installed package {pkg}')
except ModuleNotFoundError:
pass
try:
# override with values from local package
mod = import_module(f'{pkg}.{pkg}.djangoldp_settings')
update_with(mod.__dict__)
logger.debug(f'Updating settings from local package {pkg}')
except ModuleNotFoundError:
pass
# look in settings.py file in directory
try:
mod = import_module('settings')
update_with(mod.__dict__)
logger.debug(f'Updating settings from local settings.py file')
except ModuleNotFoundError:
pass
# look in YAML config file 'server' section
try:
conf = self._config.get('server', {})
update_with(conf)
logger.debug(f'Updating settings with project config')
except KeyError:
pass
# In the end adds the INSTALLED_APPS from the core
settings['INSTALLED_APPS'].extend(getattr(default_settings,'INSTALLED_APPS'))
return settings
@property
def DJANGOLDP_PACKAGES(self):
"""Returns the list of LDP packages configured."""
pkg = self._config.get('ldppackages', [])
return [] if pkg is None else pkg
@property
def INSTALLED_APPS(self):
"""Return the installed apps and the LDP packages."""
# get ldp packages (they are django apps)
apps = self.DJANGOLDP_PACKAGES.copy()
# add the default apps
apps.extend(self._settings['INSTALLED_APPS'])
# As settings come from different origins duplicuation is likeliy to happen
return list(OrderedDict.fromkeys(apps))
def __getattr__(self, param):
"""Return the requested parameter from cached settings."""
if param.startswith('_') or param.islower():
# raise the django exception for inexistent parameter
raise AttributeError(f'"{param}" is not compliant to django settings format')
try:
return self._settings[param]
except KeyError:
# raise the django exception for inexistent parameter
raise AttributeError(f'no "{param}" parameter found in settings')
def is_overridden(self, setting):
return setting in self._explicit_settings
\ No newline at end of file
# {{ app_name }}
__version__ = '0.0.0'
from django.contrib import admin