Repository: python-discord/site

Issue:
Migrate hidden warnings to notes
The new `Infraction` model has a [specific type field option](https://github.com/python-discord/site/blob/master/pydis_site/apps/api/models/bot/infraction.py#L8-L19) for notes that we recently started using (python-discord/bot#452). This means that we're now storing notes as `"note"` in the database and no longer use the misnomer of "hidden warning".

However, there are still a lot of `"hidden warning"` infractions in the database that were set prior to merging the PR referenced above. I think it's a good idea to perform a data migration to turn these `"warning"` infractions into `"note"` infractions:

```py
from django.db import migrations


def migrate_hidden_warnings_to_notes(apps, schema_editor):
    """Migrates hidden warnings to notes."""
    Infraction = apps.get_model('api', 'Infraction')

    for infraction in Infraction.objects.filter(type="warning", hidden=True):
        infraction.type = "note"
        infraction.save()


class Migration(migrations.Migration):

    dependencies = [
        ('api', '0042_infraction_add_default_ordering'),
    ]

    operations = [
        migrations.RunPython(migrate_hidden_warnings_to_notes),
    ]
```

This will mean that notes will now consistently be called "note", both in the back-end as the front-end (bot).

---

In addition to migrating the data, I think it's a good idea to decouple the terms "warning" and "note" completely by adding validation rules for infractions that state that `"warnings"` can never have `hidden=True` and `"notes"` can never have `"hidden=False"`. This way, they truly become different entities, instead of having a "hidden warning" as some sort of implicit synonym for notes.

We already do this with for other incompatible combinations of field values, like a `"kick"` infraction with an expiration date or a `"superstarify"` infraction with `hidden=True`, so this would be merely an extension of the current validation rules instead of introducing validation completely to the infraction endpoint.


Discussion:

Resolving patch:
diff --git a/pydis_site/apps/api/migrations/0043_infraction_hidden_warnings_to_notes.py b/pydis_site/apps/api/migrations/0043_infraction_hidden_warnings_to_notes.py
new file mode 100644
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0043_infraction_hidden_warnings_to_notes.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.2.5 on 2019-09-25 08:41
+
+from django.db import migrations
+
+
+def migrate_hidden_warnings_to_notes(apps, schema_editor):
+    """Migrates hidden warnings to note."""
+    Infraction = apps.get_model('api', 'Infraction')
+
+    for infraction in Infraction.objects.filter(type="warning", hidden=True):
+        infraction.type = "note"
+        infraction.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0042_infraction_add_default_ordering'),
+    ]
+
+    operations = [
+        migrations.RunPython(migrate_hidden_warnings_to_notes),
+    ]
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -115,9 +115,12 @@ def validate(self, attrs: dict) -> dict:
             raise ValidationError({'expires_at': [f'{infr_type} infractions cannot expire.']})
 
         hidden = attrs.get('hidden')
-        if hidden and infr_type in ('superstar',):
+        if hidden and infr_type in ('superstar', 'warning'):
             raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']})
 
+        if not hidden and infr_type in ('note', ):
+            raise ValidationError({'hidden': [f'{infr_type} infractions must be hidden.']})
+
         return attrs
 
 


Test patch:
diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py
--- a/pydis_site/apps/api/tests/test_infractions.py
+++ b/pydis_site/apps/api/tests/test_infractions.py
@@ -257,32 +257,52 @@ def test_returns_400_for_bad_expired_at_format(self):
 
     def test_returns_400_for_expiring_non_expirable_type(self):
         url = reverse('bot:infraction-list', host='api')
-        data = {
-            'user': self.user.id,
-            'actor': self.user.id,
-            'type': 'kick',
-            'expires_at': '5018-11-20T15:52:00+00:00'
-        }
 
-        response = self.client.post(url, data=data)
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'expires_at': [f'{data["type"]} infractions cannot expire.']
-        })
+        for infraction_type in ('kick', 'warning'):
+            data = {
+                'user': self.user.id,
+                'actor': self.user.id,
+                'type': infraction_type,
+                'expires_at': '5018-11-20T15:52:00+00:00'
+            }
+
+            response = self.client.post(url, data=data)
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(response.json(), {
+                'expires_at': [f'{data["type"]} infractions cannot expire.']
+            })
 
     def test_returns_400_for_hidden_non_hideable_type(self):
         url = reverse('bot:infraction-list', host='api')
+
+        for infraction_type in ('superstar', 'warning'):
+            data = {
+                'user': self.user.id,
+                'actor': self.user.id,
+                'type': infraction_type,
+                'hidden': True
+            }
+
+            response = self.client.post(url, data=data)
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(response.json(), {
+                'hidden': [f'{data["type"]} infractions cannot be hidden.']
+            })
+
+    def test_returns_400_for_non_hidden_required_hidden_type(self):
+        url = reverse('bot:infraction-list', host='api')
+
         data = {
             'user': self.user.id,
             'actor': self.user.id,
-            'type': 'superstar',
-            'hidden': True
+            'type': 'note',
+            'hidden': False,
         }
 
         response = self.client.post(url, data=data)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
-            'hidden': [f'{data["type"]} infractions cannot be hidden.']
+            'hidden': [f'{data["type"]} infractions must be hidden.']
         })