From 032d0ba38d814f25aba5ecb0782a9d6efda7ac65 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Thu, 30 Apr 2020 16:43:14 +0100 Subject: [PATCH] Added sender and receiver support for Remove Activity --- djangoldp/activities/services.py | 94 ++++++++++++++++++++++++++++---- djangoldp/activities/verbs.py | 29 ++++++++++ djangoldp/serializers.py | 3 +- djangoldp/tests/tests_inbox.py | 59 ++++++++++++++++++++ djangoldp/views.py | 53 ++++++++++++++---- 5 files changed, 214 insertions(+), 24 deletions(-) diff --git a/djangoldp/activities/services.py b/djangoldp/activities/services.py index 46932867..04e1f6e6 100644 --- a/djangoldp/activities/services.py +++ b/djangoldp/activities/services.py @@ -96,6 +96,49 @@ class ActivityPubService(object): print() print() + @classmethod + def send_remove_activity(cls, actor, object, origin): + ''' + Sends a Remove activity + :param actor: a valid Actor object, or a user instance + :param object: 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 + } + + print('objectid ' + str(object['@id'])) + print('originid ' + str(origin['@id'])) + 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 + } + + print('built activity ' + str(activity)) + + # TODO: inbox discovery + inbox = origin['@id'] + "inbox/" + + # send request + t = threading.Thread(target=cls.do_post, args=[inbox, activity]) + t.start() + print() + print() + + @classmethod def do_post(cls, url, activity, auth=None): headers = {'Content-Type': 'application/ld+json'} @@ -147,20 +190,23 @@ def check_object_for_backlinks(sender, instance, **kwargs): @receiver([m2m_changed]) def check_m2m_for_backlinks(sender, instance, action, *args, **kwargs): + member_model = kwargs['model'] + pk_set = kwargs['pk_set'] + member_rdf_type = Model.get_model_rdf_type(member_model) + container_rdf_type = Model.get_model_rdf_type(type(instance)) + + if member_rdf_type is None or container_rdf_type is None: + print( + 'an rdf_type was None! (member: ' + str(member_rdf_type) + ", container: " + str(container_rdf_type) + ")") + return + + # build list of targets (models affected by the change) + query_set = member_model.objects.filter(pk__in=pk_set) + targets = [] + if action == 'post_add': - print('-m2m_changed_listener-') - member_model = kwargs['model'] - pk_set = kwargs['pk_set'] - member_rdf_type = Model.get_model_rdf_type(member_model) - container_rdf_type = Model.get_model_rdf_type(type(instance)) - - if member_rdf_type is None or container_rdf_type is None: - print('an rdf_type was None! (member: ' + str(member_rdf_type) + ", container: " + str(container_rdf_type) + ")") - return + print('-m2m_post_add_listener-') - # build list of targets (new models added to the relation) - query_set = member_model.objects.filter(pk__in=pk_set) - targets = [] for obj in query_set: if Model.is_external(obj) and getattr(instance, 'allow_create_backlink', False): targets.append({ @@ -181,3 +227,27 @@ def check_m2m_for_backlinks(sender, instance, action, *args, **kwargs): "type": "Service", "name": "Backlinks Service" }, obj, target) + + elif action == "post_remove": + print('-m2m_post_remove_listener-') + + for obj in query_set: + if Model.is_external(obj): + targets.append({ + "@type": member_rdf_type, + "@id": obj.urlid + }) + + print('built targets: ' + str(targets)) + + if len(targets) > 0: + obj = { + "@type": container_rdf_type, + "@id": instance.urlid + } + print('sending object: ' + str(obj)) + for target in targets: + ActivityPubService.send_remove_activity({ + "type": "Service", + "name": "Backlinks Service" + }, obj, target) diff --git a/djangoldp/activities/verbs.py b/djangoldp/activities/verbs.py index bdec0468..14b80e9e 100644 --- a/djangoldp/activities/verbs.py +++ b/djangoldp/activities/verbs.py @@ -55,7 +55,36 @@ class Add(Activity): raise errors.ActivityStreamValidationError(msg) +class Remove(Activity): + type = "Remove" + attributes = Activity.attributes + ["target", "origin"] + + def validate(self): + msg = None + if not getattr(self, "actor", None): + msg = "Invalid activity, actor is missing" + elif not getattr(self, "object", None): + msg = "Invalid activity, object is missing" + elif not getattr(self, "target", None) and not getattr(self, "origin", None): + msg = "Invalid activity, no target or origin given" + elif not isinstance(self.actor, Actor) and not isinstance(self.actor, str): + msg = "Invalid actor type, must be an Actor or a string" + elif not isinstance(self.object, dict): + msg = "Invalid object type, must be a dict" + + if getattr(self, "target", None) is not None: + if not isinstance(self.target, dict): + msg = "Invalid target type, must be a dict" + if getattr(self, "origin", None) is not None: + if not isinstance(self.origin, dict): + msg = "Invalid origin type, must be a dict" + + if msg: + raise errors.ActivityStreamValidationError(msg) + + ALLOWED_TYPES.update({ "Activity": Activity, "Add": Add, + "Remove": Remove, }) diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index 995967b3..f7289408 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -47,9 +47,10 @@ class LDListMixin: return [getattr(self, self.child_attr).to_internal_value(item) for item in data] - # converts internal representation to primitive data representation def to_representation(self, value): ''' + Converts internal representation to primitive data representation + Filters objects out which I don't have permission to view Permission on container : - Can Add if add permission on contained object's type - Can view the container is view permission on container model : container obj are filtered by view permission diff --git a/djangoldp/tests/tests_inbox.py b/djangoldp/tests/tests_inbox.py index 0beeb669..6b08f49b 100644 --- a/djangoldp/tests/tests_inbox.py +++ b/djangoldp/tests/tests_inbox.py @@ -10,6 +10,9 @@ class TestsInbox(APITestCase): def setUp(self): self.client = APIClient(enforce_csrf_checks=True) + # + # ADD ACTIVITIES + # # project model has a direct many-to-many with User def test_add_activity_project(self): print('>>test_add_activity_project') @@ -110,6 +113,8 @@ class TestsInbox(APITestCase): print() print() + # TODO: adding to a model which has a duplicate relationship + # error behaviour - unknown model def test_add_activity_unknown(self): # a local user has joined a distant circle @@ -141,3 +146,57 @@ class TestsInbox(APITestCase): response = self.client.post('/users/{}/inbox/'.format(user.pk), data=json.dumps(payload), content_type='application/ld+json;profile="https://www.w3.org/ns/activitystreams"') self.assertEqual(response.status_code, 404) + + # + # REMOVE & DELETE ACTIVITIES + # + # project model has a direct many-to-many with User + def test_remove_activity_project(self): + print('>>test_remove_activity_project') + # a local user has a distant project attached + user = get_user_model().objects.create(username='john', email='jlennon@beatles.com', password='glass onion') + UserProfile.objects.create(user=user) + project = Project.objects.create(urlid="https://distant.com/projects/1/", allow_create_backlink=False) + user.projects.add(project) + + payload = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + {"hd": "http://happy-dev.fr/owl/#"} + ], + "summary": user.get_full_name() + " removed Test Project", + "type": "Remove", + "actor": { + "type": "Service", + "name": "Backlinks Service" + }, + "object": { + "@type": "hd:project", + "@id": "https://distant.com/projects/1/" + }, + "origin": { + "@type": "foaf:user", + "name": user.get_full_name(), + "@id": user.urlid + } + } + + response = self.client.post('/users/{}/inbox/'.format(user.pk), + data=json.dumps(payload), + content_type='application/ld+json;profile="https://www.w3.org/ns/activitystreams"') + self.assertEqual(response.status_code, 201) + + # assert that the circle backlink(s) were removed & activity were created + projects = Project.objects.all() + user_projects = user.projects.all() + activities = Activity.objects.all() + self.assertEquals(len(projects), 1) + self.assertEquals(len(user_projects), 0) + self.assertEquals(len(activities), 1) + self.assertIn("https://distant.com/projects/1/", projects.values_list('urlid', flat=True)) + self.assertIn(response["Location"], activities.values_list('urlid', flat=True)) + print() + print() + + # TODO: error behaviour - project does not exist on user + # TODO: Delete CircleMember diff --git a/djangoldp/views.py b/djangoldp/views.py index a84e4300..1153ad78 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -99,21 +99,20 @@ class InboxModelMixin: return_value = Model.get_or_create(object_model, object['@id'], **branches) return return_value + # 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 ''' - # create the backlinked object - object_model = Model.get_subclass_with_rdf_type(activity.object['@type']) - target_model = Model.get_subclass_with_rdf_type(activity.target['@type']) - - # TODO: a fallback here? Saving the backlink as Object or similar - if object_model is None: - raise Http404('unable to store type ' + activity.object['@type'] + ', model not found') - - if target_model is None: - raise Http404('unable to store type ' + activity.target['@type'] + ', model not found') + 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']) # store backlink(s) in database # TODO: permissions @@ -125,14 +124,44 @@ class InboxModelMixin: # add object to target try: target_info = model_meta.get_field_info(target_model) + target = target_model.objects.get(urlid=activity.target['@id']) for field_name, relation_info in target_info.relations.items(): if relation_info.related_model == object_model: - target = target_model.objects.get(urlid=activity.target['@id']) getattr(target, field_name).add(backlink) except target_model.DoesNotExist: return Response({}, status=status.HTTP_404_NOT_FOUND) + 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: + object_instance = object_model.objects.get(urlid=activity.object['@id']) + except KeyError: + return Response({'error': '@type or @id missing on passed object'}, status=status.HTTP_400_BAD_REQUEST) + except object_model.DoesNotExist: + return + + # TODO: permissions + # remove object from origin + try: + origin_info = model_meta.get_field_info(origin_model) + origin = origin_model.objects.get(urlid=activity.origin['@id']) + + for field_name, relation_info in origin_info.relations.items(): + if relation_info.related_model == object_model: + getattr(origin, field_name).remove(object_instance) + # TODO: decipher from history if the resource has been moved? + except origin_model.DoesNotExist: + return Response({}, status=status.HTTP_404_NOT_FOUND) + def receive_notification(self, request, pk=None, *args, **kwargs): ''' receiver for inbox messages. See https://www.w3.org/TR/ldn/ @@ -147,6 +176,8 @@ class InboxModelMixin: if activity.type == 'Add': self.handle_add_activity(activity, **kwargs) + if activity.type == 'Remove': + self.handle_remove_activity(activity, **kwargs) # save the activity and return 201 payload = bytes(json.dumps(activity.to_json()), "utf-8") -- GitLab