Repository: bookwyrm-social/bookwyrm

Issue:
Tasks processing deleted statuses from Mastodon fail
Most or all status deletion notifications I get in flower failed. Somehow, there are many of them, they currently make more than 60% of all existent tasks.

![image](https://user-images.githubusercontent.com/66644064/115127721-09703400-9fd9-11eb-86d7-dc0d7f9902ca.png)

Details: `Exception | ActivitySerializerError('Missing required field: cc')`

```
Traceback (most recent call last):
  File "/app/bookwyrm/activitypub/base_activity.py", line 77, in __init__
    value = kwargs[field.name]
KeyError: 'cc'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/celery/app/trace.py", line 385, in trace_task
    R = retval = fun(*args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/celery/app/trace.py", line 650, in __protected_call__
    return self.run(*args, **kwargs)
  File "/app/bookwyrm/views/inbox.py", line 93, in activity_task
    activity = activitypub.parse(activity_json)
  File "/app/bookwyrm/activitypub/__init__.py", line 31, in parse
    return naive_parse(activity_objects, activity_json)
  File "/app/bookwyrm/activitypub/base_activity.py", line 61, in naive_parse
    return serializer(activity_objects=activity_objects, **activity_json)
  File "/app/bookwyrm/activitypub/base_activity.py", line 98, in __init__
    raise ActivitySerializerError(
bookwyrm.activitypub.base_activity.ActivitySerializerError: Missing required field: cc
```
There are some from mastodon.online as well so I guess it's a problem with Mastodon in general, even though pipou.academy sends out a lot of deletes.


Discussion:

Resolving patch:
diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py
--- a/bookwyrm/activitypub/verbs.py
+++ b/bookwyrm/activitypub/verbs.py
@@ -16,11 +16,10 @@ class Verb(ActivityObject):
 
     def action(self):
         """ usually we just want to update and save """
-        obj = self.object
-        # it may return None if the object is invalid in an expected way
+        # self.object may return None if the object is invalid in an expected way
         # ie, Question type
-        if obj:
-            obj.to_model()
+        if self.object:
+            self.object.to_model()
 
 
 @dataclass(init=False)
@@ -43,7 +42,16 @@ class Delete(Verb):
 
     def action(self):
         """ find and delete the activity object """
-        obj = self.object.to_model(save=False, allow_create=False)
+        if not self.object:
+            return
+
+        if isinstance(self.object, str):
+            # Deleted users are passed as strings. Not wild about this fix
+            model = apps.get_model("bookwyrm.User")
+            obj = model.find_existing_by_remote_id(self.object)
+        else:
+            obj = self.object.to_model(save=False, allow_create=False)
+
         if obj:
             obj.delete()
         # if we can't find it, we don't need to delete it because we don't have it
@@ -58,8 +66,7 @@ class Update(Verb):
 
     def action(self):
         """ update a model instance from the dataclass """
-        obj = self.object
-        if obj:
+        if self.object:
             self.object.to_model(allow_create=False)
 
 
diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py
--- a/bookwyrm/models/activitypub_mixin.py
+++ b/bookwyrm/models/activitypub_mixin.py
@@ -241,7 +241,9 @@ def save(self, *args, created=None, **kwargs):
             return
 
         # is this a deletion?
-        if hasattr(self, "deleted") and self.deleted:
+        if (hasattr(self, "deleted") and self.deleted) or (
+            hasattr(self, "is_active") and not self.is_active
+        ):
             activity = self.to_delete_activity(user)
         else:
             activity = self.to_update_activity(user)
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -205,6 +205,9 @@ def to_followers_activity(self, **kwargs):
     def to_activity(self, **kwargs):
         """override default AP serializer to add context object
         idk if this is the best way to go about this"""
+        if not self.is_active:
+            return self.remote_id
+
         activity_object = super().to_activity(**kwargs)
         activity_object["@context"] = [
             "https://www.w3.org/ns/activitystreams",
@@ -283,6 +286,12 @@ def save(self, *args, **kwargs):
                 editable=False,
             ).save(broadcast=False)
 
+    def delete(self, *args, **kwargs):
+        """ deactivate rather than delete a user """
+        self.is_active = False
+        # skip the logic in this class's save()
+        super().save(*args, **kwargs)
+
     @property
     def local_path(self):
         """ this model doesn't inherit bookwyrm model, so here we are """


Test patch:
diff --git a/bookwyrm/tests/models/test_user_model.py b/bookwyrm/tests/models/test_user_model.py
--- a/bookwyrm/tests/models/test_user_model.py
+++ b/bookwyrm/tests/models/test_user_model.py
@@ -1,4 +1,5 @@
 """ testing models """
+import json
 from unittest.mock import patch
 from django.test import TestCase
 import responses
@@ -152,3 +153,17 @@ def test_get_or_create_remote_server_unknown_format(self):
         self.assertEqual(server.server_name, DOMAIN)
         self.assertIsNone(server.application_type)
         self.assertIsNone(server.application_version)
+
+    def test_delete_user(self):
+        """ deactivate a user """
+        self.assertTrue(self.user.is_active)
+        with patch(
+            "bookwyrm.models.activitypub_mixin.broadcast_task.delay"
+        ) as broadcast_mock:
+            self.user.delete()
+
+        self.assertEqual(broadcast_mock.call_count, 1)
+        activity = json.loads(broadcast_mock.call_args[0][1])
+        self.assertEqual(activity["type"], "Delete")
+        self.assertEqual(activity["object"], self.user.remote_id)
+        self.assertFalse(self.user.is_active)
diff --git a/bookwyrm/tests/views/inbox/test_inbox_delete.py b/bookwyrm/tests/views/inbox/test_inbox_delete.py
--- a/bookwyrm/tests/views/inbox/test_inbox_delete.py
+++ b/bookwyrm/tests/views/inbox/test_inbox_delete.py
@@ -49,7 +49,7 @@ def setUp(self):
         }
         models.SiteSettings.objects.create()
 
-    def test_handle_delete_status(self):
+    def test_delete_status(self):
         """ remove a status """
         self.assertFalse(self.status.deleted)
         activity = {
@@ -70,7 +70,7 @@ def test_handle_delete_status(self):
         self.assertTrue(status.deleted)
         self.assertIsInstance(status.deleted_date, datetime)
 
-    def test_handle_delete_status_notifications(self):
+    def test_delete_status_notifications(self):
         """ remove a status with related notifications """
         models.Notification.objects.create(
             related_status=self.status,
@@ -104,3 +104,34 @@ def test_handle_delete_status_notifications(self):
         # notifications should be truly deleted
         self.assertEqual(models.Notification.objects.count(), 1)
         self.assertEqual(models.Notification.objects.get(), notif)
+
+    def test_delete_user(self):
+        """ delete a user """
+        self.assertTrue(models.User.objects.get(username="rat@example.com").is_active)
+        activity = {
+            "@context": "https://www.w3.org/ns/activitystreams",
+            "id": "https://example.com/users/test-user#delete",
+            "type": "Delete",
+            "actor": "https://example.com/users/test-user",
+            "to": ["https://www.w3.org/ns/activitystreams#Public"],
+            "object": self.remote_user.remote_id,
+        }
+
+        views.inbox.activity_task(activity)
+        self.assertFalse(models.User.objects.get(username="rat@example.com").is_active)
+
+    def test_delete_user_unknown(self):
+        """ don't worry about it if we don't know the user """
+        self.assertEqual(models.User.objects.filter(is_active=True).count(), 2)
+        activity = {
+            "@context": "https://www.w3.org/ns/activitystreams",
+            "id": "https://example.com/users/test-user#delete",
+            "type": "Delete",
+            "actor": "https://example.com/users/test-user",
+            "to": ["https://www.w3.org/ns/activitystreams#Public"],
+            "object": "https://example.com/users/test-user",
+        }
+
+        # nothing happens.
+        views.inbox.activity_task(activity)
+        self.assertEqual(models.User.objects.filter(is_active=True).count(), 2)