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