diff --git a/djangoldp/activities/services.py b/djangoldp/activities/services.py index c3379a5831ec50575a3212e16843dc0e2982c912..4b817d9fe383876abfdadc8a7f0e12ddae4ea2f7 100644 --- a/djangoldp/activities/services.py +++ b/djangoldp/activities/services.py @@ -7,6 +7,7 @@ from django.dispatch import receiver from rest_framework.utils import model_meta from djangoldp.models import Model, Follower +from djangoldp.models import Activity as ActivityModel from .objects import * from .verbs import * @@ -55,228 +56,184 @@ class ActivityPubService(object): return obj @classmethod - def _discover_inbox(cls, target_id): - url = urlparse(target_id) - return target_id.replace(url.path, "/") + "inbox/" - - @classmethod - def send_add_activity(cls, actor, object, target): - ''' - Sends an Add activity - :param actor: a valid Actor object, or a user instance - :param object: a valid ActivityStreams Object - :param target: an object representing the target collection - ''' - # bounds checking - if isinstance(actor, get_user_model()): - actor = { + def get_actor_from_user_instance(cls, user): + '''Auxiliary function returns valid Actor object from parameterised user instance, None if parameter invalid''' + if isinstance(user, get_user_model()) and hasattr(user, 'urlid'): + return { '@type': 'foaf:user', - '@id': actor.urlid + '@id': user.urlid } + return None - summary = str(object['@id']) + " was added to " + str(target['@id']) + @classmethod + def discover_inbox(cls, target_id): + url = urlparse(target_id) + return target_id.replace(url.path, "/") + "inbox/" - activity = { + @classmethod + def _build_activity(self, actor, obj, activity_type='Activity', **kwargs): + '''Auxiliary function returns an activity object with kwargs in the body''' + res = { "@context": [ "https://www.w3.org/ns/activitystreams", settings.LDP_RDF_CONTEXT ], - "summary": summary, - "type": "Add", + "type": activity_type, "actor": actor, - "object": object, - "target": target + "object": obj } - logger.debug('[Sender] sending add activity ' + str(activity)) + for kwarg in kwargs: + res.update({kwarg: kwargs[kwarg]}) - inbox = ActivityPubService._discover_inbox(target['@id']) + return res + + @classmethod + def send_add_activity(cls, actor, obj, target): + ''' + Sends an Add activity + :param actor: a valid Actor object + :param obj: a valid ActivityStreams Object + :param target: an object representing the target collection + ''' + summary = str(obj['@id']) + " was added to " + str(target['@id']) + activity = cls._build_activity(actor, obj, activity_type='Add', summary=summary, target=target) # send request + inbox = ActivityPubService.discover_inbox(target['@id']) t = threading.Thread(target=cls.do_post, args=[inbox, activity]) t.start() + cls._save_sent_activity(activity) @classmethod - def send_remove_activity(cls, actor, object, origin): + def send_remove_activity(cls, actor, obj, origin): ''' Sends a Remove activity :param actor: a valid Actor object, or a user instance - :param object: a valid ActivityStreams Object + :param obj: a valid ActivityStreams Object :param origin: the context the object has been removed from ''' - # bounds checking - if isinstance(actor, get_user_model()): - actor = { - '@type': 'foaf:user', - '@id': actor.urlid - } - - summary = str(object['@id']) + " was removed from " + str(origin['@id']) - - activity = { - "@context": [ - "https://www.w3.org/ns/activitystreams", - settings.LDP_RDF_CONTEXT - ], - "summary": summary, - "type": "Remove", - "actor": actor, - "object": object, - "origin": origin - } - - logger.debug('[Sender] sending remove activity ' + str(activity)) - - inbox = ActivityPubService._discover_inbox(origin['@id']) + summary = str(obj['@id']) + " was removed from " + str(origin['@id']) + activity = cls._build_activity(actor, obj, activity_type='Remove', summary=summary, origin=origin) # send request + inbox = ActivityPubService.discover_inbox(origin['@id']) t = threading.Thread(target=cls.do_post, args=[inbox, activity]) t.start() + cls._save_sent_activity(activity) @classmethod - def send_create_activity(cls, actor, object, inbox): + def send_create_activity(cls, actor, obj, inbox): ''' Sends a Create activity :param actor: a valid Actor object, or a user instance - :param object: a valid ActivityStreams Object + :param obj: a valid ActivityStreams Object :param inbox: the inbox to send the activity to ''' - # bounds checking - if isinstance(actor, get_user_model()): - actor = { - '@type': 'foaf:user', - '@id': actor.urlid - } - - summary = str(object['@id']) + " was created" - - activity = { - "@context": [ - "https://www.w3.org/ns/activitystreams", - settings.LDP_RDF_CONTEXT - ], - "summary": summary, - "type": "Create", - "actor": actor, - "object": object - } - - logger.debug('[Sender] sending create activity ' + str(activity)) + summary = str(obj['@id']) + " was created" + activity = cls._build_activity(actor, obj, activity_type='Create', summary=summary) # send request t = threading.Thread(target=cls.do_post, args=[inbox, activity]) t.start() + cls._save_sent_activity(activity) @classmethod - def send_update_activity(cls, actor, object, inbox): + def send_update_activity(cls, actor, obj, inbox): ''' Sends an Update activity :param actor: a valid Actor object, or a user instance - :param object: a valid ActivityStreams Object + :param obj: a valid ActivityStreams Object :param inbox: the inbox to send the activity to ''' - # bounds checking - if isinstance(actor, get_user_model()): - actor = { - '@type': 'foaf:user', - '@id': actor.urlid - } - - summary = str(object['@id']) + " was updated" - - activity = { - "@context": [ - "https://www.w3.org/ns/activitystreams", - settings.LDP_RDF_CONTEXT - ], - "summary": summary, - "type": "Update", - "actor": actor, - "object": object - } - - logger.debug('[Sender] sending update activity ' + str(activity)) + summary = str(obj['@id']) + " was updated" + activity = cls._build_activity(actor, obj, activity_type='Update', summary=summary) # send request t = threading.Thread(target=cls.do_post, args=[inbox, activity]) t.start() + cls._save_sent_activity(activity) @classmethod - def send_delete_activity(cls, actor, object, inbox): + def send_delete_activity(cls, actor, obj, inbox): ''' Sends a Remove activity :param actor: a valid Actor object, or a user instance - :param object: a valid ActivityStreams Object + :param obj: a valid ActivityStreams Object :param inbox: the inbox to send the activity to ''' - # bounds checking - if isinstance(actor, get_user_model()): - actor = { - '@type': 'foaf:user', - '@id': actor.urlid - } - - summary = str(object['@id']) + " was deleted" - - activity = { - "@context": [ - "https://www.w3.org/ns/activitystreams", - settings.LDP_RDF_CONTEXT - ], - "summary": summary, - "type": "Delete", - "actor": actor, - "object": object - } - - logger.debug('[Sender] sending delete activity ' + str(activity)) + summary = str(obj['@id']) + " was deleted" + activity = cls._build_activity(actor, obj, activity_type='Delete', summary=summary) # send request t = threading.Thread(target=cls.do_post, args=[inbox, activity]) t.start() + cls._save_sent_activity(activity) + + @classmethod + def _save_sent_activity(cls, activity): + '''Auxiliary function saves a record of parameterised activity''' + payload = bytes(json.dumps(activity), "utf-8") + local_id = settings.SITE_URL + "outbox/" + obj = ActivityModel.objects.create(local_id=local_id, payload=payload) + obj.aid = Model.absolute_url(obj) + obj.save() @classmethod def do_post(cls, url, activity, auth=None): - '''makes a POST request to passed url''' + ''' + makes a POST request to url, passing activity (json) content + :return: response, or None if the request was unsuccessful + ''' headers = {'Content-Type': 'application/ld+json'} response = None try: - response = requests.post(url, data=json.dumps(activity), headers=headers) - logger.debug('[Sender] sent, receiver responded ' + response.text) + logger.debug('[Sender] sending Activity... ' + str(activity)) + if not getattr(settings, 'DISABLE_OUTBOX', False): + response = requests.post(url, data=json.dumps(activity), headers=headers) + logger.debug('[Sender] sent, receiver responded ' + response.text) except: logger.error('Failed to deliver backlink to ' + str(url) +', was attempting ' + str(activity)) return response + @classmethod + def get_related_externals(cls, sender, instance): + '''Auxiliary function returns a set of urlids of distant resources connected to paramertised instance''' + info = model_meta.get_field_info(sender) -def _check_instance_for_backlinks(sender, instance): - '''Auxiliary function returns a set of backlink targets from paramertised instance''' - info = model_meta.get_field_info(sender) + # bounds checking + if not hasattr(instance, 'urlid') or Model.get_model_rdf_type(sender) is None: + return set() - # bounds checking - if not hasattr(instance, 'urlid') or Model.get_model_rdf_type(sender) is None: - return {} + # check each foreign key for a distant resource + targets = set() + for field_name, relation_info in info.relations.items(): + if not relation_info.to_many: + value = getattr(instance, field_name, None) + logger.debug('[Sender] model has relation ' + str(value)) + if value is not None and Model.is_external(value): + target_type = Model.get_model_rdf_type(type(value)) - # check each foreign key for a distant resource - targets = set() - for field_name, relation_info in info.relations.items(): - if not relation_info.to_many: - value = getattr(instance, field_name, None) - logger.debug('[Sender] model has relation ' + str(value)) - if value is not None and Model.is_external(value): - target_type = Model.get_model_rdf_type(type(value)) + if target_type is None: + continue - if target_type is None: - continue + targets.add(value.urlid) - targets.add(ActivityPubService._discover_inbox(value.urlid)) + return targets - # append Followers as targets - followers = Follower.objects.filter(object=instance.urlid) - for follower in followers: - targets.add(follower.inbox) + @classmethod + def get_target_inboxes(cls, urlids): + '''Auxiliary function returns a set of inboxes, from a set of target object urlids''' + inboxes = set() + for urlid in urlids: + inboxes.add(ActivityPubService.discover_inbox(urlid)) + return inboxes - logger.debug('[Sender] built set of targets: ' + str(targets)) - return targets + @classmethod + def get_follower_inboxes(cls, 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)) + return inboxes @receiver([post_save]) @@ -284,8 +241,9 @@ def check_save_for_backlinks(sender, instance, created, **kwargs): if getattr(settings, 'SEND_BACKLINKS', True) and getattr(instance, 'allow_create_backlink', False) \ and not Model.is_external(instance) \ and getattr(instance, 'username', None) != 'hubl-workaround-493': - logger.debug("[Sender] Received created non-backlink instance " + str(instance) + "(" + str(sender) + ")") - targets = _check_instance_for_backlinks(sender, instance) + external_urlids = ActivityPubService.get_related_externals(sender, instance) + inboxes = ActivityPubService.get_follower_inboxes(instance.urlid) + targets = set().union(ActivityPubService.get_target_inboxes(external_urlids), inboxes) if len(targets) > 0: obj = ActivityPubService.build_object_tree(instance) @@ -297,21 +255,24 @@ def check_save_for_backlinks(sender, instance, created, **kwargs): if created: for target in targets: ActivityPubService.send_create_activity(actor, obj, target) - Follower.objects.create(object=obj['@id'], inbox=target) # Update Activity else: for target in targets: ActivityPubService.send_update_activity(actor, obj, target) - if not Follower.objects.filter(object=obj['@id'], inbox=target).exists(): - Follower.objects.create(object=obj['@id'], inbox=target) + + # create Followers to update external resources of changes in future + existing_followers = Follower.objects.filter(object=obj['@id']).values_list('follower', flat=True) + for urlid in external_urlids: + if urlid not in existing_followers: + Follower.objects.create(object=obj['@id'], inbox=ActivityPubService.discover_inbox(urlid), + follower=urlid, is_backlink=True) @receiver([post_delete]) def check_delete_for_backlinks(sender, instance, **kwargs): if getattr(settings, 'SEND_BACKLINKS', True) and getattr(instance, 'allow_create_backlink', False) \ and getattr(instance, 'username', None) != 'hubl-workaround-493': - logger.debug("[Sender] Received deleted non-backlink instance " + str(instance) + "(" + str(sender) + ")") - targets = _check_instance_for_backlinks(sender, instance) + targets = ActivityPubService.get_follower_inboxes(instance.urlid) if len(targets) > 0: for target in targets: @@ -384,6 +345,10 @@ def check_m2m_for_backlinks(sender, instance, action, *args, **kwargs): "type": "Service", "name": "Backlinks Service" }, obj, target) + inbox = ActivityPubService.discover_inbox(target['@id']) + if not Follower.objects.filter(object=obj['@id'], follower=target['@id']).exists(): + Follower.objects.create(object=obj['@id'], inbox=inbox, follower=target['@id'], + is_backlink=True) elif action == "post_remove" or action == "pre_clear": for target in targets: @@ -391,3 +356,6 @@ def check_m2m_for_backlinks(sender, instance, action, *args, **kwargs): "type": "Service", "name": "Backlinks Service" }, obj, target) + for follower in Follower.objects.filter(object=obj['@id'], follower=target['@id'], + is_backlink=True): + follower.delete() diff --git a/djangoldp/migrations/0013_auto_20200624_1709.py b/djangoldp/migrations/0013_auto_20200624_1709.py new file mode 100644 index 0000000000000000000000000000000000000000..2fd89ca6a560682408f5042d52b0619e0f9e099c --- /dev/null +++ b/djangoldp/migrations/0013_auto_20200624_1709.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-06-24 17:09 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangoldp', '0012_auto_20200617_1817'), + ] + + operations = [ + migrations.AddField( + model_name='follower', + name='follower', + field=models.URLField(blank=True, help_text='(optional) the resource/actor following the object'), + ), + migrations.AlterField( + model_name='follower', + name='inbox', + field=models.URLField(help_text='the inbox recipient of updates'), + ), + migrations.AlterField( + model_name='follower', + name='object', + field=models.URLField(help_text='the object being followed'), + ), + ] diff --git a/djangoldp/models.py b/djangoldp/models.py index fb99c63801464bad0aa0723bd4b1668b79e74864..d970ac35402eb1c409d171690a6d60a734c36af4 100644 --- a/djangoldp/models.py +++ b/djangoldp/models.py @@ -273,8 +273,9 @@ class Activity(Model): class Follower(Model): '''Models a subscription on a model. When the model is saved, an Update activity will be sent to the inbox''' - object = models.URLField() - inbox = models.URLField() + object = models.URLField(help_text='the object being followed') + inbox = models.URLField(help_text='the inbox recipient of updates') + follower = models.URLField(help_text='(optional) the resource/actor following the object', blank=True) def __str__(self): return 'Inbox ' + str(self.inbox) + ' on ' + str(self.object) diff --git a/djangoldp/tests/runner.py b/djangoldp/tests/runner.py index 66794179358e79c6d062775c6cabcc64623226f8..af89c6b874dac8a703ed4fcb1ea802455b4c0e23 100644 --- a/djangoldp/tests/runner.py +++ b/djangoldp/tests/runner.py @@ -24,6 +24,7 @@ failures = test_runner.run_tests([ 'djangoldp.tests.tests_sources', 'djangoldp.tests.tests_pagination', 'djangoldp.tests.tests_inbox', + 'djangoldp.tests.tests_backlinks_service', #'djangoldp.tests.tests_temp' ]) if failures: diff --git a/djangoldp/tests/tests_backlinks_service.py b/djangoldp/tests/tests_backlinks_service.py new file mode 100644 index 0000000000000000000000000000000000000000..05dcde6a0c816689fed2aeb61c084f749399522f --- /dev/null +++ b/djangoldp/tests/tests_backlinks_service.py @@ -0,0 +1,91 @@ +import json +import uuid +from django.contrib.auth import get_user_model +from django.db import IntegrityError +from django.test import override_settings +from rest_framework.test import APIClient, APITestCase +from djangoldp.tests.models import Circle, CircleMember, Project, UserProfile, DateModel, DateChild +from djangoldp.models import Activity, Follower + + +class TestsBacklinksService(APITestCase): + + def setUp(self): + self.client = APIClient(enforce_csrf_checks=True) + self.local_user = get_user_model().objects.create_user(username='john', email='jlennon@beatles.com', + password='glass onion') + + def _get_random_external_user(self): + '''Auxiliary function creates a user with random external urlid and returns it''' + username = str(uuid.uuid4()) + email = username + '@test.com' + urlid = 'https://distant.com/users/' + username + return get_user_model().objects.create_user(username=username, email=email, password='test', urlid=urlid) + + @override_settings(SEND_BACKLINKS=True, DISABLE_OUTBOX=True) + def test_local_object_with_distant_foreign_key(self): + # a local Circle with a distant owner + local_circle = Circle.objects.create(description='Test') + external_user = self._get_random_external_user() + local_circle.owner = external_user + local_circle.save() + + # assert that a activity was sent + self.assertEqual(Activity.objects.all().count(), 1) + + # reset to a local user, another (update) activity should be sent + local_circle.owner = self.local_user + local_circle.save() + self.assertEqual(Activity.objects.all().count(), 2) + + # external user should no longer be following the object. A further update should not send an activity + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/257 + '''another_user = get_user_model().objects.create_user(username='test', email='test@test.com', + password='glass onion') + local_circle.owner = another_user + local_circle.save() + self.assertEqual(Activity.objects.all().count(), 2)''' + + # re-add the external user as owner + local_circle.owner = external_user + local_circle.save() + + # delete parent + local_circle.delete() + self.assertEqual(Activity.objects.all().count(), 4) + + @override_settings(SEND_BACKLINKS=True, DISABLE_OUTBOX=True) + def test_local_object_with_external_m2m_join_leave(self): + # a local project with three distant users + project = Project.objects.create(description='Test') + external_a = self._get_random_external_user() + external_b = self._get_random_external_user() + external_c = self._get_random_external_user() + project.team.add(external_a) + project.team.add(external_b) + project.team.add(external_c) + self.assertEqual(Activity.objects.all().count(), 3) + + # remove one individual + project.team.remove(external_a) + self.assertEqual(Activity.objects.all().count(), 4) + + # clear the rest + project.team.clear() + self.assertEqual(Activity.objects.all().count(), 6) + prior_count = Activity.objects.all().count() + + # once removed I should not be following the object anymore + project.delete() + self.assertEqual(Activity.objects.all().count(), prior_count) + + @override_settings(SEND_BACKLINKS=True, DISABLE_OUTBOX=True) + def test_local_object_with_external_m2m_delete_parent(self): + # a local project with three distant users + project = Project.objects.create(description='Test') + external_a = self._get_random_external_user() + project.team.add(external_a) + prior_count = Activity.objects.all().count() + + project.delete() + self.assertEqual(Activity.objects.all().count(), prior_count + 1) diff --git a/djangoldp/tests/tests_inbox.py b/djangoldp/tests/tests_inbox.py index 532fb676b37388fb80a5c7ece13c48a2f13c1088..b11f7ddbe78a5146ba6c2c21bf275da53361b5f8 100644 --- a/djangoldp/tests/tests_inbox.py +++ b/djangoldp/tests/tests_inbox.py @@ -10,9 +10,8 @@ class TestsInbox(APITestCase): def setUp(self): self.client = APIClient(enforce_csrf_checks=True) - self.user = get_user_model().objects.create(username='john', email='jlennon@beatles.com', - password='glass onion') - self.profile = UserProfile.objects.create(user=self.user) + self.user = get_user_model().objects.create_user(username='john', email='jlennon@beatles.com', + password='glass onion') def _get_activity_request_template(self, type, obj, target=None, origin=None): res = {