Repository: python-discord/site

Issue:
Support rescheduling of offensive messages
When a message trips the filter on the bot it is removed after a period of time. During this period it is a record in the database.

When this deletion date is reached the bot will attempt to remove the message from Discord and remove the record from the offensive message table. We currently handle for the message being not found (deleted) but if another error occurs resulting in the message not being deleted we still continue to delete the database record, causing the message to be left around.

We should allow the bot to perform a PATCH request to the deleted message endpoint to update the delete time and reschedule if something has failed (for example, a hiccup on Discord's end).

However, we must also bear in mind that permanent rescheduling could potentially leave lingering records in our database if a case is discovered where a message which cannot be deleted is rescheduled repetitively.


Discussion:
This issue is blocking python-discord/bot#1013
If the bot has a way of interacting with staff as a whole:
- have a hardcoded retry limit bigger than 1 that will be decremented every time deleting the message is unsuccessful (not due to it being deleted)
- once limit is at 1, send a notification to staff as an embed; the embed has an up arrow and down arrow emoji
- if up arrow is selected, the retry limit is incremented
- if down arrow is selected the limit is decremented (to 0) and the message is deleted
- if nothing is selected, on the next retry the message will be deleted

Could also have a circle arrow that would reset the limit (in addition or instead of the up arrow)

Thoughts?
That really seems overkill IMO, we could just retry a fixed amount of time, and then forget about it. 
This is easy enough to do, it wouldn't hurt. Assigned @jchristgit.

Resolving patch:
diff --git a/pydis_site/apps/api/viewsets/bot/offensive_message.py b/pydis_site/apps/api/viewsets/bot/offensive_message.py
--- a/pydis_site/apps/api/viewsets/bot/offensive_message.py
+++ b/pydis_site/apps/api/viewsets/bot/offensive_message.py
@@ -1,6 +1,7 @@
 from rest_framework.mixins import (
     CreateModelMixin,
     DestroyModelMixin,
+    UpdateModelMixin,
     ListModelMixin
 )
 from rest_framework.viewsets import GenericViewSet
@@ -10,7 +11,7 @@
 
 
 class OffensiveMessageViewSet(
-    CreateModelMixin, ListModelMixin, DestroyModelMixin, GenericViewSet
+    CreateModelMixin, ListModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet
 ):
     """
     View providing CRUD access to offensive messages.
@@ -46,6 +47,16 @@ class OffensiveMessageViewSet(
     - 201: returned on success
     - 400: if the body format is invalid
 
+    ### PATCH /bot/offensive-messages/<id:int>
+    Perform a partial update of the offensive message with the given `id`.
+    Intended to allow rescheduling the deletion date in case the bot's attempt
+    to delete the message failed due to another error than the message already
+    being deleted.
+
+    #### Status codes
+    - 200: returned on success
+    - 404: if a offensive message object with the given `id` does not exist
+
     ### DELETE /bot/offensive-messages/<id:int>
     Delete the offensive message object with the given `id`.
 


Test patch:
diff --git a/pydis_site/apps/api/tests/test_offensive_message.py b/pydis_site/apps/api/tests/test_offensive_message.py
--- a/pydis_site/apps/api/tests/test_offensive_message.py
+++ b/pydis_site/apps/api/tests/test_offensive_message.py
@@ -6,6 +6,17 @@
 from pydis_site.apps.api.models import OffensiveMessage
 
 
+def create_offensive_message() -> OffensiveMessage:
+    """Creates and returns an `OffensiveMessgage` record for tests."""
+    delete_at = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1)
+
+    return OffensiveMessage.objects.create(
+        id=602951077675139072,
+        channel_id=291284109232308226,
+        delete_date=delete_at,
+    )
+
+
 class CreationTests(AuthenticatedAPITestCase):
     def test_accept_valid_data(self):
         url = reverse('api:bot:offensivemessage-list')
@@ -111,13 +122,7 @@ def test_get_data(self):
 class DeletionTests(AuthenticatedAPITestCase):
     @classmethod
     def setUpTestData(cls):
-        delete_at = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1)
-
-        cls.valid_offensive_message = OffensiveMessage.objects.create(
-            id=602951077675139072,
-            channel_id=291284109232308226,
-            delete_date=delete_at.isoformat()
-        )
+        cls.valid_offensive_message = create_offensive_message()
 
     def test_delete_data(self):
         url = reverse(
@@ -132,24 +137,45 @@ def test_delete_data(self):
         )
 
 
-class NotAllowedMethodsTests(AuthenticatedAPITestCase):
+class UpdateOffensiveMessageTestCase(AuthenticatedAPITestCase):
     @classmethod
     def setUpTestData(cls):
-        delete_at = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1)
+        cls.message = create_offensive_message()
+        cls.in_one_week = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=7)
 
-        cls.valid_offensive_message = OffensiveMessage.objects.create(
-            id=602951077675139072,
-            channel_id=291284109232308226,
-            delete_date=delete_at.isoformat()
+    def test_updating_message(self):
+        url = reverse('api:bot:offensivemessage-detail', args=(self.message.id,))
+        data = {'delete_date': self.in_one_week.isoformat()}
+        update_response = self.client.patch(url, data=data)
+        self.assertEqual(update_response.status_code, 200)
+
+        self.message.refresh_from_db()
+        self.assertAlmostEqual(
+            self.message.delete_date,
+            self.in_one_week,
+            delta=datetime.timedelta(seconds=1),
         )
 
-    def test_returns_405_for_patch_and_put_requests(self):
-        url = reverse(
-            'api:bot:offensivemessage-detail', args=(self.valid_offensive_message.id,)
+    def test_updating_nonexistent_message(self):
+        url = reverse('api:bot:offensivemessage-detail', args=(self.message.id + 1,))
+        data = {'delete_date': self.in_one_week}
+
+        response = self.client.patch(url, data=data)
+        self.assertEqual(response.status_code, 404)
+        self.message.refresh_from_db()
+        self.assertNotAlmostEqual(
+            self.message.delete_date,
+            self.in_one_week,
+            delta=datetime.timedelta(seconds=1),
         )
-        not_allowed_methods = (self.client.patch, self.client.put)
 
-        for method in not_allowed_methods:
-            with self.subTest(method=method):
-                response = method(url, {})
-                self.assertEqual(response.status_code, 405)
+
+class NotAllowedMethodsTests(AuthenticatedAPITestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.message = create_offensive_message()
+
+    def test_returns_405_for_get(self):
+        url = reverse('api:bot:offensivemessage-detail', args=(self.message.id,))
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 405)