From 2f5effe075287dab4965f3278031bcd433a83f7c Mon Sep 17 00:00:00 2001
From: Matteo Bertucci 
Date: Mon, 26 Apr 2021 17:17:06 +0200
Subject: Filter: new schema
This commit adds new filter schema as described in #479
---
 pydis_site/apps/api/models/__init__.py          |   5 +
 pydis_site/apps/api/models/bot/__init__.py      |   2 +-
 pydis_site/apps/api/models/bot/filter_list.py   |  42 ------
 pydis_site/apps/api/models/bot/filters.py       | 187 ++++++++++++++++++++++++
 pydis_site/apps/api/viewsets/bot/filter_list.py |   2 +-
 5 files changed, 194 insertions(+), 44 deletions(-)
 delete mode 100644 pydis_site/apps/api/models/bot/filter_list.py
 create mode 100644 pydis_site/apps/api/models/bot/filters.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index fd5bf220..72f59b57 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -1,6 +1,11 @@
 # flake8: noqa
 from .bot import (
     FilterList,
+    FilterSettings,
+    FilterAction,
+    ChannelRange,
+    Filter,
+    FilterOverride,
     BotSetting,
     DocumentationLink,
     DeletedMessage,
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index ac864de3..1bfe0063 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -1,5 +1,5 @@
 # flake8: noqa
-from .filter_list import FilterList
+from .filters import FilterList, FilterSettings, FilterAction, ChannelRange, Filter, FilterOverride
 from .bot_setting import BotSetting
 from .deleted_message import DeletedMessage
 from .documentation_link import DocumentationLink
diff --git a/pydis_site/apps/api/models/bot/filter_list.py b/pydis_site/apps/api/models/bot/filter_list.py
deleted file mode 100644
index d30f7213..00000000
--- a/pydis_site/apps/api/models/bot/filter_list.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from django.db import models
-
-from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin
-
-
-class FilterList(ModelTimestampMixin, ModelReprMixin, models.Model):
-    """An item that is either allowed or denied."""
-
-    FilterListType = models.TextChoices(
-        'FilterListType',
-        'GUILD_INVITE '
-        'FILE_FORMAT '
-        'DOMAIN_NAME '
-        'FILTER_TOKEN '
-        'REDIRECT '
-    )
-    type = models.CharField(
-        max_length=50,
-        help_text="The type of allowlist this is on.",
-        choices=FilterListType.choices,
-    )
-    allowed = models.BooleanField(
-        help_text="Whether this item is on the allowlist or the denylist."
-    )
-    content = models.TextField(
-        help_text="The data to add to the allow or denylist."
-    )
-    comment = models.TextField(
-        help_text="Optional comment on this entry.",
-        null=True
-    )
-
-    class Meta:
-        """Metaconfig for this model."""
-
-        # This constraint ensures only one filterlist with the
-        # same content can exist. This means that we cannot have both an allow
-        # and a deny for the same item, and we cannot have duplicates of the
-        # same item.
-        constraints = [
-            models.UniqueConstraint(fields=['content', 'type'], name='unique_filter_list'),
-        ]
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
new file mode 100644
index 00000000..dfc38e82
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -0,0 +1,187 @@
+from typing import List
+
+from django.contrib.postgres.fields import ArrayField
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.db.models import UniqueConstraint
+
+
+class FilterListType(models.IntegerChoices):
+    """Choice between allow or deny for a list type."""
+
+    ALLOW: 1
+    DENY: 0
+
+
+class InfractionType(models.TextChoices):
+    """Possible type of infractions."""
+
+    NOTE = "Note"
+    WARN = "Warn"
+    MUTE = "Mute"
+    KICK = "Kick"
+    BAN = "Ban"
+
+
+# Valid special values in ping related fields
+VALID_PINGS = ("everyone", "here", "moderators", "onduty", "admins")
+
+
+def validate_ping_field(value_list: List[str]) -> None:
+    """Validate that the values are either a special value or a UID."""
+    for value in value_list:
+        # Check if it is a special value
+        if value in VALID_PINGS:
+            continue
+        # Check if it is a UID
+        if value.isnumeric():
+            continue
+
+        raise ValidationError(f"{value!r} isn't a valid ping type.")
+
+
+class FilterList(models.Model):
+    """Represent a list in its allow or deny form."""
+
+    name = models.CharField(max_length=50, help_text="The unique name of this list.")
+    list_type = models.IntegerField(
+        choices=FilterListType.choices,
+        help_text="Whenever this list is an allowlist or denylist"
+    )
+
+    filters = models.ManyToManyField("Filter", help_text="The content of this list.")
+    default_settings = models.ForeignKey(
+        "FilterSettings",
+        models.CASCADE,
+        help_text="Default parameters of this list."
+    )
+
+    class Meta:
+        """Constrain name and list_type unique."""
+
+        constraints = (
+            UniqueConstraint(fields=("name", "list_type"), name="unique_name_type"),
+        )
+
+    def __str__(self) -> str:
+        return f"Filter {'allow' if self.list_type == 1 else 'deny'}list {self.name!r}"
+
+
+class FilterSettings(models.Model):
+    """Persistent settings of a filter list."""
+
+    ping_type = ArrayField(
+        models.CharField(max_length=20),
+        validators=(validate_ping_field,),
+        help_text="Who to ping when this filter triggers."
+    )
+    filter_dm = models.BooleanField(help_text="Whenever DMs should be filtered.")
+    dm_ping_type = ArrayField(
+        models.CharField(max_length=20),
+        validators=(validate_ping_field,),
+        help_text="Who to ping when this filter triggers on a DM."
+    )
+    delete_messages = models.BooleanField(
+        help_text="Whenever this filter should delete messages triggering it."
+    )
+    bypass_roles = ArrayField(
+        models.BigIntegerField(),
+        help_text="Roles and users who can bypass this filter."
+    )
+    enabled = models.BooleanField(
+        help_text="Whenever ths filter is currently enabled."
+    )
+    default_action = models.ForeignKey(
+        "FilterAction",
+        models.CASCADE,
+        help_text="The default action to perform."
+    )
+    default_range = models.ForeignKey(
+        "ChannelRange",
+        models.CASCADE,
+        help_text="Where does this filter apply."
+    )
+
+
+class FilterAction(models.Model):
+    """The action to take when a filter is triggered."""
+
+    user_dm = models.CharField(
+        max_length=1000,
+        null=True,
+        help_text="The DM to send to a user triggering this filter."
+    )
+    infraction_type = models.CharField(
+        choices=InfractionType.choices,
+        max_length=4,
+        null=True,
+        help_text="The infraction to apply to this user."
+    )
+    infraction_reason = models.CharField(
+        max_length=1000,
+        help_text="The reason to give for the infraction."
+    )
+    infraction_duration = models.DurationField(
+        null=True,
+        help_text="The duration of the infraction. Null if permanent."
+    )
+
+
+class ChannelRange(models.Model):
+    """
+    Where a filter should apply.
+
+    The resolution is done in the following order:
+    - disallowed channels
+    - disallowed categories
+    - allowed categories
+    - allowed channels
+    - default
+    """
+
+    disallowed_channels = ArrayField(models.IntegerField())
+    disallowed_categories = ArrayField(models.IntegerField())
+    allowed_channels = ArrayField(models.IntegerField())
+    allowed_category = ArrayField(models.IntegerField())
+    default = models.BooleanField()
+
+
+class Filter(models.Model):
+    """One specific trigger of a list."""
+
+    content = models.CharField(max_length=100, help_text="The definition of this filter.")
+    description = models.CharField(max_length=200, help_text="Why this filter has been added.")
+    additional_field = models.BooleanField(null=True, help_text="Implementation specific field.")
+    override = models.ForeignKey(
+        "FilterOverride",
+        models.SET_NULL,
+        null=True,
+        help_text="Override the default settings."
+    )
+
+    def __str__(self) -> str:
+        return f"Filter {self.content!r}"
+
+
+class FilterOverride(models.Model):
+    """
+    Setting overrides of a specific filter.
+
+    Any non-null value will override the default ones.
+    """
+
+    ping_type = ArrayField(
+        models.CharField(max_length=20),
+        validators=(validate_ping_field,), null=True
+    )
+    filter_dm = models.BooleanField(null=True)
+    dm_ping_type = ArrayField(
+        models.CharField(max_length=20),
+        validators=(validate_ping_field,),
+        null=True
+    )
+    delete_messages = models.BooleanField(null=True)
+    bypass_roles = ArrayField(models.IntegerField(), null=True)
+    enabled = models.BooleanField(null=True)
+    default_action = models.ForeignKey("FilterAction", models.CASCADE, null=True)
+    default_range = models.ForeignKey("ChannelRange", models.CASCADE, null=True)
diff --git a/pydis_site/apps/api/viewsets/bot/filter_list.py b/pydis_site/apps/api/viewsets/bot/filter_list.py
index 4b05acee..3eacdaaa 100644
--- a/pydis_site/apps/api/viewsets/bot/filter_list.py
+++ b/pydis_site/apps/api/viewsets/bot/filter_list.py
@@ -3,7 +3,7 @@ from rest_framework.request import Request
 from rest_framework.response import Response
 from rest_framework.viewsets import ModelViewSet
 
-from pydis_site.apps.api.models.bot.filter_list import FilterList
+from pydis_site.apps.api.models.bot.filters import FilterList
 from pydis_site.apps.api.serializers import FilterListSerializer
 
 
-- 
cgit v1.2.3
From c6bcca08e58855cf3c3f87602f752dd40b10efad Mon Sep 17 00:00:00 2001
From: Matteo Bertucci 
Date: Mon, 26 Apr 2021 17:29:01 +0200
Subject: Filters: Add new models to Django Admin
---
 pydis_site/apps/api/admin.py | 12 ++++++++++++
 1 file changed, 12 insertions(+)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py
index 2aca38a1..e123d150 100644
--- a/pydis_site/apps/api/admin.py
+++ b/pydis_site/apps/api/admin.py
@@ -13,6 +13,8 @@ from .models import (
     BotSetting,
     DeletedMessage,
     DocumentationLink,
+    Filter,
+    FilterList,
     Infraction,
     MessageDeletionContext,
     Nomination,
@@ -194,6 +196,16 @@ class DeletedMessageInline(admin.TabularInline):
     model = DeletedMessage
 
 
+@admin.register(FilterList)
+class FilterListAdmin(admin.ModelAdmin):
+    """Admin formatting for the FilterList model."""
+
+
+@admin.register(Filter)
+class FilterAdmin(admin.ModelAdmin):
+    """Admin formatting for the Filter model."""
+
+
 @admin.register(MessageDeletionContext)
 class MessageDeletionContextAdmin(admin.ModelAdmin):
     """Admin formatting for the MessageDeletionContext model."""
-- 
cgit v1.2.3
From 87c78ceb49f6a2a0ab268fa2dde1850df5506eee Mon Sep 17 00:00:00 2001
From: Matteo Bertucci 
Date: Mon, 26 Apr 2021 17:30:10 +0200
Subject: Filters: Add migration to the new model
This will take the currently defined filter list and put them inside the new schema while trying to keep defaults similar to our current setup.
---
 .../apps/api/migrations/0070_new_filter_schema.py  | 165 +++++++++++++++++++++
 1 file changed, 165 insertions(+)
 create mode 100644 pydis_site/apps/api/migrations/0070_new_filter_schema.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
new file mode 100644
index 00000000..e6d7ffe7
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -0,0 +1,165 @@
+# Modified migration file to migrate existing filters to the new one
+
+import django.contrib.postgres.fields
+from django.apps.registry import Apps
+from django.db import migrations, models
+import django.db.models.deletion
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+import pydis_site.apps.api.models.bot.filters
+
+OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY'))
+
+
+def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+    filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
+    filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
+    filter_settings: pydis_site.apps.api.models.FilterSettings = apps.get_model("api", "FilterSettings")
+    channel_range: pydis_site.apps.api.models.ChannelRange = apps.get_model("api", "ChannelRange")
+    filter_action: pydis_site.apps.api.models.FilterAction = apps.get_model("api", "FilterAction")
+    filter_list_old = apps.get_model("api", "FilterListOld")
+
+    for name, type_ in OLD_LIST_NAMES:
+        objects = filter_list_old.objects.filter(type=name)
+
+        default_action = filter_action.objects.create(
+            user_dm=None,
+            infraction_type=None,
+            infraction_reason="",
+            infraction_duration=None
+        )
+        default_action.save()
+        default_range = channel_range.objects.create(
+            disallowed_channels=[],
+            disallowed_categories=[],
+            allowed_channels=[],
+            allowed_category=[],
+            default=True
+        )
+        default_range.save()
+        default_settings = filter_settings.objects.create(
+            ping_type=["onduty"],
+            filter_dm=True,
+            dm_ping_type=["onduty"],
+            delete_messages=True,
+            bypass_roles=[267630620367257601],
+            enabled=False,
+            default_action=default_action,
+            default_range=default_range
+        )
+        default_settings.save()
+        list_ = filter_list.objects.create(
+            name=name.lower(),
+            default_settings=default_settings,
+            list_type=1 if type_ == "ALLOW" else 0
+        )
+
+        new_objects = []
+        for object_ in objects:
+            new_object = filter_.objects.create(
+                content=object_.content,
+                description=object_.comment or "",
+                additional_field=None, override=None
+            )
+            new_object.save()
+            new_objects.append(new_object)
+
+        list_.filters.add(*new_objects)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0069_documentationlink_validators'),
+    ]
+
+    operations = [
+        migrations.RenameModel(
+            old_name='FilterList',
+            new_name='FilterListOld'
+        ),
+        migrations.CreateModel(
+            name='ChannelRange',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('allowed_category', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('default', models.BooleanField()),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Filter',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('content', models.CharField(help_text='The definition of this filter.', max_length=100)),
+                ('description', models.CharField(help_text='Why this filter has been added.', max_length=200)),
+                ('additional_field', models.BooleanField(help_text='Implementation specific field.', null=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='FilterAction',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('user_dm', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
+                ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)),
+                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)),
+                ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='FilterSettings',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
+                ('filter_dm', models.BooleanField(help_text='Whenever DMs should be filtered.')),
+                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
+                ('delete_messages', models.BooleanField(help_text='Whenever this filter should delete messages triggering it.')),
+                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None)),
+                ('enabled', models.BooleanField(help_text='Whenever ths filter is currently enabled.')),
+                ('default_action', models.ForeignKey(help_text='The default action to perform.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')),
+                ('default_range', models.ForeignKey(help_text='Where does this filter apply.', on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='FilterOverride',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), null=True, size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
+                ('filter_dm', models.BooleanField(null=True)),
+                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), null=True, size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
+                ('delete_messages', models.BooleanField(null=True)),
+                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
+                ('enabled', models.BooleanField(null=True)),
+                ('default_action', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')),
+                ('default_range', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='FilterList',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(help_text='The unique name of this list.', max_length=50)),
+                ('list_type', models.IntegerField(choices=[], help_text='Whenever this list is an allowlist or denylist')),
+                ('default_settings', models.ForeignKey(help_text='Default parameters of this list.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterSettings')),
+                ('filters', models.ManyToManyField(help_text='The content of this list.', to='api.Filter')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='filter',
+            name='override',
+            field=models.ForeignKey(help_text='Override the default settings.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.FilterOverride'),
+        ),
+        migrations.AddConstraint(
+            model_name='filterlist',
+            constraint=models.UniqueConstraint(fields=('name', 'list_type'), name='unique_name_type'),
+        ),
+        migrations.RunPython(
+            code=forward,  # Core of the migration
+            reverse_code=lambda *_: None
+        ),
+        migrations.DeleteModel(
+            name='FilterListOld'
+        )
+    ]
-- 
cgit v1.2.3
From 64c5c617df68cc7b54fffbe8d76bb5c67d641c98 Mon Sep 17 00:00:00 2001
From: Matteo Bertucci 
Date: Tue, 27 Apr 2021 10:54:21 +0200
Subject: Filters: hook the new models into the REST API
---
 .../apps/api/migrations/0070_new_filter_schema.py  |   4 +-
 pydis_site/apps/api/models/bot/filters.py          |   4 +-
 pydis_site/apps/api/serializers.py                 |  98 +++-
 pydis_site/apps/api/urls.py                        |  29 +-
 pydis_site/apps/api/viewsets/__init__.py           |   7 +-
 pydis_site/apps/api/viewsets/bot/__init__.py       |   9 +-
 pydis_site/apps/api/viewsets/bot/filter_list.py    |  98 ----
 pydis_site/apps/api/viewsets/bot/filters.py        | 640 +++++++++++++++++++++
 8 files changed, 773 insertions(+), 116 deletions(-)
 delete mode 100644 pydis_site/apps/api/viewsets/bot/filter_list.py
 create mode 100644 pydis_site/apps/api/viewsets/bot/filters.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index e6d7ffe7..f4fc9494 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -132,8 +132,8 @@ class Migration(migrations.Migration):
                 ('delete_messages', models.BooleanField(null=True)),
                 ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
                 ('enabled', models.BooleanField(null=True)),
-                ('default_action', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')),
-                ('default_range', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')),
+                ('filter_action', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')),
+                ('filter_range', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')),
             ],
         ),
         migrations.CreateModel(
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index dfc38e82..16ac206e 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -183,5 +183,5 @@ class FilterOverride(models.Model):
     delete_messages = models.BooleanField(null=True)
     bypass_roles = ArrayField(models.IntegerField(), null=True)
     enabled = models.BooleanField(null=True)
-    default_action = models.ForeignKey("FilterAction", models.CASCADE, null=True)
-    default_range = models.ForeignKey("ChannelRange", models.CASCADE, null=True)
+    filter_action = models.ForeignKey("FilterAction", models.CASCADE, null=True)
+    filter_range = models.ForeignKey("ChannelRange", models.CASCADE, null=True)
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index de2fccff..306dccb3 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -12,12 +12,17 @@ from rest_framework.serializers import (
 from rest_framework.settings import api_settings
 from rest_framework.validators import UniqueTogetherValidator
 
-from .models import (
+from .models import (  # noqa: I101 - Preserving the filter order
     BotSetting,
     DeletedMessage,
     DocumentationLink,
-    FilterList,
     Infraction,
+    FilterList,
+    FilterSettings,
+    FilterAction,
+    ChannelRange,
+    Filter,
+    FilterOverride,
     MessageDeletionContext,
     Nomination,
     NominationEntry,
@@ -119,24 +124,97 @@ class FilterListSerializer(ModelSerializer):
         """Metadata defined for the Django REST Framework."""
 
         model = FilterList
-        fields = ('id', 'created_at', 'updated_at', 'type', 'allowed', 'content', 'comment')
+        fields = ('id', 'name', 'list_type', 'filters', 'default_settings')
 
-        # This validator ensures only one filterlist with the
-        # same content can exist. This means that we cannot have both an allow
-        # and a deny for the same item, and we cannot have duplicates of the
-        # same item.
+        # Ensure that we can only have one filter list with the same name and field
         validators = [
             UniqueTogetherValidator(
                 queryset=FilterList.objects.all(),
-                fields=['content', 'type'],
+                fields=('name', 'list_type'),
                 message=(
-                    "A filterlist for this item already exists. "
-                    "Please note that you cannot add the same item to both allow and deny."
+                    "A filterlist with the same name and type already exist."
                 )
             ),
         ]
 
 
+class FilterSettingsSerializer(ModelSerializer):
+    """A class providing (de-)serialization of `FilterSettings` instances."""
+
+    class Meta:
+        """Metadata defined for the Django REST Framework."""
+
+        model = FilterSettings
+        fields = (
+            'id',
+            'ping_type',
+            'filter_dm',
+            'dm_ping_type',
+            'delete_messages',
+            'bypass_roles',
+            'enabled',
+            'default_action',
+            'default_range'
+        )
+
+
+class FilterActionSerializer(ModelSerializer):
+    """A class providing (de-)serialization of `FilterAction` instances."""
+
+    class Meta:
+        """Metadata defined for the Django REST Framework."""
+
+        model = FilterAction
+        fields = ('id', 'user_dm', 'infraction_type', 'infraction_reason', 'infraction_duration')
+
+
+class FilterChannelRangeSerializer(ModelSerializer):
+    """A class providing (de-)serialization of `ChannelRange` instances."""
+
+    class Meta:
+        """Metadata defined for the Django REST Framework."""
+
+        model = ChannelRange
+        fields = (
+            'id',
+            'disallowed_channels',
+            'disallowed_categories',
+            'allowed_channels',
+            'allowed_category',
+            'default'
+        )
+
+
+class FilterSerializer(ModelSerializer):
+    """A class providing (de-)serialization of `Filter` instances."""
+
+    class Meta:
+        """Metadata defined for the Django REST Framework."""
+
+        model = Filter
+        fields = ('id', 'content', 'description', 'additional_field', 'override')
+
+
+class FilterOverrideSerializer(ModelSerializer):
+    """A class providing (de-)serialization of `FilterOverride` instances."""
+
+    class Meta:
+        """Metadata defined for the Django REST Framework."""
+
+        model = FilterOverride
+        fields = (
+            'id',
+            'ping_type',
+            'filter_dm',
+            'dm_ping_type',
+            'delete_messages',
+            'bypass_roles',
+            'enabled',
+            'filter_action',
+            'filter_range'
+        )
+
+
 class InfractionSerializer(ModelSerializer):
     """A class providing (de-)serialization of `Infraction` instances."""
 
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index b0ab545b..7af2e505 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -2,11 +2,16 @@ from django.urls import include, path
 from rest_framework.routers import DefaultRouter
 
 from .views import HealthcheckView, RulesView
-from .viewsets import (
+from .viewsets import (  # noqa: I101 - Preserving the filter order
     BotSettingViewSet,
     DeletedMessageViewSet,
     DocumentationLinkViewSet,
     FilterListViewSet,
+    FilterSettingsViewSet,
+    FilterActionViewSet,
+    FilterChannelRangeViewSet,
+    FilterViewSet,
+    FilterOverrideViewSet,
     InfractionViewSet,
     NominationViewSet,
     OffTopicChannelNameViewSet,
@@ -19,9 +24,29 @@ from .viewsets import (
 # https://www.django-rest-framework.org/api-guide/routers/#defaultrouter
 bot_router = DefaultRouter(trailing_slash=False)
 bot_router.register(
-    'filter-lists',
+    'filter/filter_lists',
     FilterListViewSet
 )
+bot_router.register(
+    'filter/filter_settings',
+    FilterSettingsViewSet
+)
+bot_router.register(
+    'filter/filter_action',
+    FilterActionViewSet
+)
+bot_router.register(
+    'filter/channel_range',
+    FilterChannelRangeViewSet
+)
+bot_router.register(
+    'filter/filter_override',
+    FilterOverrideViewSet
+)
+bot_router.register(
+    'filter/filters',
+    FilterViewSet
+)
 bot_router.register(
     'bot-settings',
     BotSettingViewSet
diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
index f133e77f..b3992d66 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -1,10 +1,15 @@
 # flake8: noqa
 from .bot import (
-    FilterListViewSet,
     BotSettingViewSet,
     DeletedMessageViewSet,
     DocumentationLinkViewSet,
     InfractionViewSet,
+    FilterListViewSet,
+    FilterSettingsViewSet,
+    FilterActionViewSet,
+    FilterChannelRangeViewSet,
+    FilterViewSet,
+    FilterOverrideViewSet,
     NominationViewSet,
     OffensiveMessageViewSet,
     OffTopicChannelNameViewSet,
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
index 84b87eab..781624bd 100644
--- a/pydis_site/apps/api/viewsets/bot/__init__.py
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -1,5 +1,12 @@
 # flake8: noqa
-from .filter_list import FilterListViewSet
+from .filters import (
+    FilterListViewSet,
+    FilterSettingsViewSet,
+    FilterActionViewSet,
+    FilterChannelRangeViewSet,
+    FilterViewSet,
+    FilterOverrideViewSet
+)
 from .bot_setting import BotSettingViewSet
 from .deleted_message import DeletedMessageViewSet
 from .documentation_link import DocumentationLinkViewSet
diff --git a/pydis_site/apps/api/viewsets/bot/filter_list.py b/pydis_site/apps/api/viewsets/bot/filter_list.py
deleted file mode 100644
index 3eacdaaa..00000000
--- a/pydis_site/apps/api/viewsets/bot/filter_list.py
+++ /dev/null
@@ -1,98 +0,0 @@
-from rest_framework.decorators import action
-from rest_framework.request import Request
-from rest_framework.response import Response
-from rest_framework.viewsets import ModelViewSet
-
-from pydis_site.apps.api.models.bot.filters import FilterList
-from pydis_site.apps.api.serializers import FilterListSerializer
-
-
-class FilterListViewSet(ModelViewSet):
-    """
-    View providing CRUD operations on items allowed or denied by our bot.
-
-    ## Routes
-    ### GET /bot/filter-lists
-    Returns all filterlist items in the database.
-
-    #### Response format
-    >>> [
-    ...     {
-    ...         'id': "2309268224",
-    ...         'created_at': "01-01-2020 ...",
-    ...         'updated_at': "01-01-2020 ...",
-    ...         'type': "file_format",
-    ...         'allowed': 'true',
-    ...         'content': ".jpeg",
-    ...         'comment': "Popular image format.",
-    ...     },
-    ...     ...
-    ... ]
-
-    #### Status codes
-    - 200: returned on success
-    - 401: returned if unauthenticated
-
-    ### GET /bot/filter-lists/
-    Returns a specific FilterList item from the database.
-
-    #### Response format
-    >>> {
-    ...     'id': "2309268224",
-    ...     'created_at': "01-01-2020 ...",
-    ...     'updated_at': "01-01-2020 ...",
-    ...     'type': "file_format",
-    ...     'allowed': 'true',
-    ...     'content': ".jpeg",
-    ...     'comment': "Popular image format.",
-    ... }
-
-    #### Status codes
-    - 200: returned on success
-    - 404: returned if the id was not found.
-
-    ### GET /bot/filter-lists/get-types
-    Returns a list of valid list types that can be used in POST requests.
-
-    #### Response format
-    >>> [
-    ...     ["GUILD_INVITE","Guild Invite"],
-    ...     ["FILE_FORMAT","File Format"],
-    ...     ["DOMAIN_NAME","Domain Name"],
-    ...     ["FILTER_TOKEN","Filter Token"],
-    ...     ["REDIRECT", "Redirect"]
-    ... ]
-
-    #### Status codes
-    - 200: returned on success
-
-    ### POST /bot/filter-lists
-    Adds a single FilterList item to the database.
-
-    #### Request body
-    >>> {
-    ...    'type': str,
-    ...    'allowed': bool,
-    ...    'content': str,
-    ...    'comment': Optional[str],
-    ... }
-
-    #### Status codes
-    - 201: returned on success
-    - 400: if one of the given fields is invalid
-
-    ### DELETE /bot/filter-lists/
-    Deletes the FilterList item with the given `id`.
-
-    #### Status codes
-    - 204: returned on success
-    - 404: if a tag with the given `id` does not exist
-    """
-
-    serializer_class = FilterListSerializer
-    queryset = FilterList.objects.all()
-
-    @action(detail=False, url_path='get-types', methods=["get"])
-    def get_types(self, _: Request) -> Response:
-        """Get a list of all the types of FilterLists we support."""
-        return Response(FilterList.FilterListType.choices)
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
new file mode 100644
index 00000000..fea53265
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -0,0 +1,640 @@
+from rest_framework.viewsets import ModelViewSet
+
+from pydis_site.apps.api.models.bot.filters import (  # noqa: I101 - Preserving the filter order
+    FilterList,
+    FilterSettings,
+    FilterAction,
+    ChannelRange,
+    Filter,
+    FilterOverride
+)
+from pydis_site.apps.api.serializers import (  # noqa: I101 - Preserving the filter order
+    FilterListSerializer,
+    FilterSettingsSerializer,
+    FilterActionSerializer,
+    FilterChannelRangeSerializer,
+    FilterSerializer,
+    FilterOverrideSerializer
+)
+
+
+class FilterListViewSet(ModelViewSet):
+    """
+    View providing CRUD operations on lists of items allowed or denied by our bot.
+
+    ## Routes
+    ### GET /bot/filter/filter_lists
+    Returns all FilterList items in the database.
+
+    #### Response format
+    >>> [
+    ...     {
+    ...         "id": 1,
+    ...         "name": "guild_invite",
+    ...         "list_type": 1,
+    ...         "filters": [
+    ...             1,
+    ...             2,
+    ...             ...
+    ...         ],
+    ...         "default_settings": 1
+    ...     },
+    ...     ...
+    ... ]
+
+    #### Status codes
+    - 200: returned on success
+    - 401: returned if unauthenticated
+
+    ### GET /bot/filter/filter_lists/
+    Returns a specific FilterList item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "name": "guild_invite",
+    ...     "list_type": 1,
+    ...     "filters": [
+    ...         1,
+    ...         2,
+    ...         ...
+    ...     ],
+    ...     "default_settings": 1
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 404: returned if the id was not found.
+
+    ### POST /bot/filter/filter_lists
+    Adds a single FilterList item to the database.
+
+    #### Request body
+    >>> {
+    ...     "name": "guild_invite",
+    ...     "list_type": 1,
+    ...     "filters": [
+    ...         1,
+    ...         2,
+    ...         ...
+    ...     ],
+    ...     "default_settings": 1
+    ... }
+
+    #### Status codes
+    - 201: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### PATCH /bot/filter/filter_lists/
+    Updates a specific FilterList item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "name": "guild_invite",
+    ...     "list_type": 1,
+    ...     "filters": [
+    ...         1,
+    ...         2,
+    ...         ...
+    ...     ],
+    ...     "default_settings": 1
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### DELETE /bot/filter/filter_lists/
+    Deletes the FilterList item with the given `id`.
+
+    #### Status codes
+    - 204: returned on success
+    - 404: if a tag with the given `id` does not exist
+    """
+
+    serializer_class = FilterListSerializer
+    queryset = FilterList.objects.all()
+
+
+class FilterSettingsViewSet(ModelViewSet):
+    """
+    View providing CRUD operations on settings of items allowed or denied by our bot.
+
+    ## Routes
+    ### GET /bot/filter/filter_settings
+    Returns all FilterSettings items in the database.
+
+    #### Response format
+    >>> [
+    ...     {
+    ...         "id": 1,
+    ...         "ping_type": [
+    ...             "onduty",
+    ...              ...
+    ...          ],
+    ...         "filter_dm": True,
+    ...         "dm_ping_type": [
+    ...             "onduty",
+    ...             ...
+    ...         ],
+    ...         "delete_messages": True,
+    ...         "bypass_roles": [
+    ...             267630620367257601,
+    ...             ...
+    ...         ],
+    ...         "enabled": True,
+    ...         "default_action": 1,
+    ...         "default_range": 1
+    ...     },
+    ...     ...
+    ... ]
+
+    #### Status codes
+    - 200: returned on success
+    - 401: returned if unauthenticated
+
+    ### GET /bot/filter/filter_settings/
+    Returns a specific FilterSettings item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "ping_type": [
+    ...         "onduty",
+    ...          ...
+    ...     ],
+    ...     "filter_dm": True,
+    ...     "dm_ping_type": [
+    ...         "onduty",
+    ...         ...
+    ...     ],
+    ...     "delete_messages": True,
+    ...     "bypass_roles": [
+    ...         267630620367257601,
+    ...         ...
+    ...     ],
+    ...     "enabled": True,
+    ...     "default_action": 1,
+    ...     "default_range": 1
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 404: returned if the id was not found.
+
+    ### POST /bot/filter/filter_settings
+    Adds a single FilterSettings item to the database.
+
+    #### Request body
+    >>> {
+    ...     "ping_type": [
+    ...         "onduty",
+    ...          ...
+    ...     ],
+    ...     "filter_dm": True,
+    ...     "dm_ping_type": [
+    ...         "onduty",
+    ...         ...
+    ...     ],
+    ...     "delete_messages": True,
+    ...     "bypass_roles": [
+    ...         267630620367257601,
+    ...         ...
+    ...     ],
+    ...     "enabled": True,
+    ...     "default_action": 1,
+    ...     "default_range": 1
+    ... }
+
+    #### Status codes
+    - 201: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### PATCH /bot/filter/filter_settings/
+    Updates a specific FilterSettings item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "ping_type": [
+    ...         "onduty",
+    ...          ...
+    ...     ],
+    ...     "filter_dm": True,
+    ...     "dm_ping_type": [
+    ...         "onduty",
+    ...         ...
+    ...     ],
+    ...     "delete_messages": True,
+    ...     "bypass_roles": [
+    ...         267630620367257601,
+    ...         ...
+    ...     ],
+    ...     "enabled": True,
+    ...     "default_action": 1,
+    ...     "default_range": 1
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### DELETE /bot/filter/filter_settings/
+    Deletes the FilterSettings item with the given `id`.
+
+    #### Status codes
+    - 204: returned on success
+    - 404: if a tag with the given `id` does not exist
+    """
+
+    serializer_class = FilterSettingsSerializer
+    queryset = FilterSettings.objects.all()
+
+
+class FilterActionViewSet(ModelViewSet):
+    """
+    View providing CRUD operations on actions taken by items allowed or denied by our bot.
+
+    ## Routes
+    ### GET /bot/filter/filter_action
+    Returns all FilterAction items in the database.
+
+    #### Response format
+    >>> [
+    ...     {
+    ...         "id": 1,
+    ...         "user_dm": "message",
+    ...         "infraction_type": "Warn",
+    ...         "infraction_reason": "",
+    ...         "infraction_duration": "01 12:34:56.123456"
+    ...     },
+    ...     ...
+    ... ]
+
+    #### Status codes
+    - 200: returned on success
+    - 401: returned if unauthenticated
+
+    ### GET /bot/filter/filter_action/
+    Returns a specific FilterAction item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "user_dm": "message",
+    ...     "infraction_type": "Warn",
+    ...     "infraction_reason": "",
+    ...     "infraction_duration": "01 12:34:56.123456"
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 404: returned if the id was not found.
+
+    ### POST /bot/filter/filter_action
+    Adds a single FilterAction item to the database.
+
+    #### Request body
+    >>> {
+    ...     "user_dm": "message",
+    ...     "infraction_type": "Warn",
+    ...     "infraction_reason": "",
+    ...     "infraction_duration": "01 12:34:56.123456"
+    ... }
+
+    #### Status codes
+    - 201: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### PATCH /bot/filter/filter_action/
+    Updates a specific FilterAction item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "user_dm": "message",
+    ...     "infraction_type": "Warn",
+    ...     "infraction_reason": "",
+    ...     "infraction_duration": "01 12:34:56.123456"
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### DELETE /bot/filter/filter_action/
+    Deletes the FilterAction item with the given `id`.
+
+    #### Status codes
+    - 204: returned on success
+    - 404: if a tag with the given `id` does not exist
+    """
+
+    serializer_class = FilterActionSerializer
+    queryset = FilterAction.objects.all()
+
+
+class FilterChannelRangeViewSet(ModelViewSet):
+    """
+    View providing CRUD operations on channels targeted by items allowed or denied by our bot.
+
+    ## Routes
+    ### GET /bot/filter/channel_range
+    Returns all ChannelRange items in the database.
+
+    #### Response format
+    >>> [
+    ...     {
+    ...         "id": 1,
+    ...         "disallowed_channels": [],
+    ...         "disallowed_categories": [],
+    ...         "allowed_channels": [],
+    ...         "allowed_category": [],
+    ...         "default": True
+    ...     },
+    ...     ...
+    ... ]
+
+    #### Status codes
+    - 200: returned on success
+    - 401: returned if unauthenticated
+
+    ### GET /bot/filter/channel_range/
+    Returns a specific ChannelRange item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "disallowed_channels": [],
+    ...     "disallowed_categories": [],
+    ...     "allowed_channels": [],
+    ...     "allowed_category": [],
+    ...     "default": True
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 404: returned if the id was not found.
+
+    ### POST /bot/filter/channel_range
+    Adds a single ChannelRange item to the database.
+
+    #### Request body
+    >>> {
+    ...     "disallowed_channels": [],
+    ...     "disallowed_categories": [],
+    ...     "allowed_channels": [],
+    ...     "allowed_category": [],
+    ...     "default": True
+    ... }
+
+    #### Status codes
+    - 201: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### PATCH /bot/filter/channel_range/
+    Updates a specific ChannelRange item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "disallowed_channels": [],
+    ...     "disallowed_categories": [],
+    ...     "allowed_channels": [],
+    ...     "allowed_category": [],
+    ...     "default": True
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### DELETE /bot/filter/channel_range/
+    Deletes the ChannelRange item with the given `id`.
+
+    #### Status codes
+    - 204: returned on success
+    - 404: if a tag with the given `id` does not exist
+    """
+
+    serializer_class = FilterChannelRangeSerializer
+    queryset = ChannelRange.objects.all()
+
+
+class FilterViewSet(ModelViewSet):
+    """
+    View providing CRUD operations on items allowed or denied by our bot.
+
+    ## Routes
+    ### GET /bot/filter/filters
+    Returns all Filter items in the database.
+
+    #### Response format
+    >>> [
+    ...     {
+    ...         "id": 1,
+    ...         "content": "267624335836053506",
+    ...         "description": "Python Discord",
+    ...         "additional_field": None,
+    ...         "override": 1
+    ...     },
+    ...     ...
+    ... ]
+
+    #### Status codes
+    - 200: returned on success
+    - 401: returned if unauthenticated
+
+    ### GET /bot/filter/filters/
+    Returns a specific Filter item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "content": "267624335836053506",
+    ...     "description": "Python Discord",
+    ...     "additional_field": None,
+    ...     "override": 1
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 404: returned if the id was not found.
+
+    ### POST /bot/filter/filters
+    Adds a single Filter item to the database.
+
+    #### Request body
+    >>> {
+    ...     "content": "267624335836053506",
+    ...     "description": "Python Discord",
+    ...     "additional_field": None,
+    ...     "override": 1
+    ... }
+
+    #### Status codes
+    - 201: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### PATCH /bot/filter/filters/
+    Updates a specific Filter item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "content": "267624335836053506",
+    ...     "description": "Python Discord",
+    ...     "additional_field": None,
+    ...     "override": 1
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### DELETE /bot/filter/filters/
+    Deletes the Filter item with the given `id`.
+
+    #### Status codes
+    - 204: returned on success
+    - 404: if a tag with the given `id` does not exist
+    """
+
+    serializer_class = FilterSerializer
+    queryset = Filter.objects.all()
+
+
+class FilterOverrideViewSet(ModelViewSet):
+    """
+    View providing CRUD operations setting overrides of items allowed or denied by our bot.
+
+    ## Routes
+    ### GET /bot/filter/filter_override
+    Returns all FilterOverride items in the database.
+
+    #### Response format
+    >>> [
+    ...     {
+    ...         "id": 1,
+    ...         "ping_type": [
+    ...             "onduty",
+    ...              ...
+    ...          ],
+    ...         "filter_dm": True,
+    ...         "dm_ping_type": [
+    ...             "onduty",
+    ...             ...
+    ...         ],
+    ...         "delete_messages": True,
+    ...         "bypass_roles": [
+    ...             267630620367257601,
+    ...             ...
+    ...         ],
+    ...         "enabled": True,
+    ...         "filter_action": 1,
+    ...         "filter_range": 1
+    ...     },
+    ...     ...
+    ... ]
+
+    #### Status codes
+    - 200: returned on success
+    - 401: returned if unauthenticated
+
+    ### GET /bot/filter/filter_override/
+    Returns a specific FilterOverride item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "ping_type": [
+    ...         "onduty",
+    ...          ...
+    ...     ],
+    ...     "filter_dm": True,
+    ...     "dm_ping_type": [
+    ...         "onduty",
+    ...         ...
+    ...     ],
+    ...     "delete_messages": True,
+    ...     "bypass_roles": [
+    ...         267630620367257601,
+    ...         ...
+    ...     ],
+    ...     "enabled": True,
+    ...     "filter_action": 1,
+    ...     "filter_range": 1
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 404: returned if the id was not found.
+
+    ### POST /bot/filter/filter_override
+    Adds a single FilterOverride item to the database.
+
+    #### Request body
+    >>> {
+    ...     "ping_type": [
+    ...         "onduty",
+    ...          ...
+    ...     ],
+    ...     "filter_dm": True,
+    ...     "dm_ping_type": [
+    ...         "onduty",
+    ...         ...
+    ...     ],
+    ...     "delete_messages": True,
+    ...     "bypass_roles": [
+    ...         267630620367257601,
+    ...         ...
+    ...     ],
+    ...     "enabled": True,
+    ...     "filter_action": 1,
+    ...     "filter_range": 1
+    ... }
+
+    #### Status codes
+    - 201: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### PATCH /bot/filter/filter_override/
+    Updates a specific FilterOverride item from the database.
+
+    #### Response format
+    >>> {
+    ...     "id": 1,
+    ...     "ping_type": [
+    ...         "onduty",
+    ...          ...
+    ...     ],
+    ...     "filter_dm": True,
+    ...     "dm_ping_type": [
+    ...         "onduty",
+    ...         ...
+    ...     ],
+    ...     "delete_messages": True,
+    ...     "bypass_roles": [
+    ...         267630620367257601,
+    ...         ...
+    ...     ],
+    ...     "enabled": True,
+    ...     "filter_action": 1,
+    ...     "filter_range": 1
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### DELETE /bot/filter/filter_override/
+    Deletes the FilterOverride item with the given `id`.
+
+    #### Status codes
+    - 204: returned on success
+    - 404: if a tag with the given `id` does not exist
+    """
+
+    serializer_class = FilterOverrideSerializer
+    queryset = FilterOverride.objects.all()
-- 
cgit v1.2.3
From c6dfd896304cb4e36c4020f4704d9537fd3e8e9f Mon Sep 17 00:00:00 2001
From: Matteo Bertucci 
Date: Tue, 27 Apr 2021 16:39:00 +0200
Subject: Filters: update tests to the new schema
---
 .../apps/api/migrations/0070_new_filter_schema.py  |   2 +-
 pydis_site/apps/api/models/bot/filters.py          |   2 +-
 pydis_site/apps/api/tests/test_filterlists.py      | 122 ---------
 pydis_site/apps/api/tests/test_filters.py          | 284 +++++++++++++++++++++
 pydis_site/apps/api/tests/test_models.py           |  14 +
 5 files changed, 300 insertions(+), 124 deletions(-)
 delete mode 100644 pydis_site/apps/api/tests/test_filterlists.py
 create mode 100644 pydis_site/apps/api/tests/test_filters.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index f4fc9494..de75e677 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -143,7 +143,7 @@ class Migration(migrations.Migration):
                 ('name', models.CharField(help_text='The unique name of this list.', max_length=50)),
                 ('list_type', models.IntegerField(choices=[], help_text='Whenever this list is an allowlist or denylist')),
                 ('default_settings', models.ForeignKey(help_text='Default parameters of this list.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterSettings')),
-                ('filters', models.ManyToManyField(help_text='The content of this list.', to='api.Filter')),
+                ('filters', models.ManyToManyField(help_text='The content of this list.', to='api.Filter', default=[])),
             ],
         ),
         migrations.AddField(
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 16ac206e..869f947c 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -49,7 +49,7 @@ class FilterList(models.Model):
         help_text="Whenever this list is an allowlist or denylist"
     )
 
-    filters = models.ManyToManyField("Filter", help_text="The content of this list.")
+    filters = models.ManyToManyField("Filter", help_text="The content of this list.", default=[])
     default_settings = models.ForeignKey(
         "FilterSettings",
         models.CASCADE,
diff --git a/pydis_site/apps/api/tests/test_filterlists.py b/pydis_site/apps/api/tests/test_filterlists.py
deleted file mode 100644
index 5a5bca60..00000000
--- a/pydis_site/apps/api/tests/test_filterlists.py
+++ /dev/null
@@ -1,122 +0,0 @@
-from django.urls import reverse
-
-from pydis_site.apps.api.models import FilterList
-from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase
-
-URL = reverse('api:bot:filterlist-list')
-JPEG_ALLOWLIST = {
-    "type": 'FILE_FORMAT',
-    "allowed": True,
-    "content": ".jpeg",
-}
-PNG_ALLOWLIST = {
-    "type": 'FILE_FORMAT',
-    "allowed": True,
-    "content": ".png",
-}
-
-
-class UnauthenticatedTests(AuthenticatedAPITestCase):
-    def setUp(self):
-        super().setUp()
-        self.client.force_authenticate(user=None)
-
-    def test_cannot_read_allowedlist_list(self):
-        response = self.client.get(URL)
-
-        self.assertEqual(response.status_code, 401)
-
-
-class EmptyDatabaseTests(AuthenticatedAPITestCase):
-    @classmethod
-    def setUpTestData(cls):
-        FilterList.objects.all().delete()
-
-    def test_returns_empty_object(self):
-        response = self.client.get(URL)
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), [])
-
-
-class FetchTests(AuthenticatedAPITestCase):
-    @classmethod
-    def setUpTestData(cls):
-        FilterList.objects.all().delete()
-        cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST)
-        cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST)
-
-    def test_returns_name_in_list(self):
-        response = self.client.get(URL)
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()[0]["content"], self.jpeg_format.content)
-        self.assertEqual(response.json()[1]["content"], self.png_format.content)
-
-    def test_returns_single_item_by_id(self):
-        response = self.client.get(f'{URL}/{self.jpeg_format.id}')
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json().get("content"), self.jpeg_format.content)
-
-    def test_returns_filter_list_types(self):
-        response = self.client.get(f'{URL}/get-types')
-
-        self.assertEqual(response.status_code, 200)
-        for api_type, model_type in zip(response.json(), FilterList.FilterListType.choices):
-            self.assertEquals(api_type[0], model_type[0])
-            self.assertEquals(api_type[1], model_type[1])
-
-
-class CreationTests(AuthenticatedAPITestCase):
-    @classmethod
-    def setUpTestData(cls):
-        FilterList.objects.all().delete()
-
-    def test_returns_400_for_missing_params(self):
-        no_type_json = {
-            "allowed": True,
-            "content": ".jpeg"
-        }
-        no_allowed_json = {
-            "type": "FILE_FORMAT",
-            "content": ".jpeg"
-        }
-        no_content_json = {
-            "allowed": True,
-            "type": "FILE_FORMAT"
-        }
-        cases = [{}, no_type_json, no_allowed_json, no_content_json]
-
-        for case in cases:
-            with self.subTest(case=case):
-                response = self.client.post(URL, data=case)
-                self.assertEqual(response.status_code, 400)
-
-    def test_returns_201_for_successful_creation(self):
-        response = self.client.post(URL, data=JPEG_ALLOWLIST)
-        self.assertEqual(response.status_code, 201)
-
-    def test_returns_400_for_duplicate_creation(self):
-        self.client.post(URL, data=JPEG_ALLOWLIST)
-        response = self.client.post(URL, data=JPEG_ALLOWLIST)
-        self.assertEqual(response.status_code, 400)
-
-
-class DeletionTests(AuthenticatedAPITestCase):
-    @classmethod
-    def setUpTestData(cls):
-        FilterList.objects.all().delete()
-        cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST)
-        cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST)
-
-    def test_deleting_unknown_id_returns_404(self):
-        response = self.client.delete(f"{URL}/200")
-        self.assertEqual(response.status_code, 404)
-
-    def test_deleting_known_id_returns_204(self):
-        response = self.client.delete(f"{URL}/{self.jpeg_format.id}")
-        self.assertEqual(response.status_code, 204)
-
-        response = self.client.get(f"{URL}/{self.jpeg_format.id}")
-        self.assertNotIn(self.png_format.content, response.json())
diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py
new file mode 100644
index 00000000..de78ecfd
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_filters.py
@@ -0,0 +1,284 @@
+import contextlib
+from dataclasses import dataclass
+from typing import Any, Dict, Tuple, Type
+
+from django.db.models import Model
+from django_hosts import reverse
+
+from pydis_site.apps.api.models.bot.filters import (  # noqa: I101 - Preserving the filter order
+    FilterList,
+    FilterSettings,
+    FilterAction,
+    ChannelRange,
+    Filter,
+    FilterOverride
+)
+from pydis_site.apps.api.tests.base import APISubdomainTestCase
+
+
+@dataclass()
+class TestSequence:
+    model: Type[Model]
+    route: str
+    object: Dict[str, Any]
+    ignored_fields: Tuple[str] = ()
+
+    def url(self, detail: bool = False) -> str:
+        return reverse(f'bot:{self.route}-{"detail" if detail else "list"}', host='api')
+
+
+FK_FIELDS: Dict[Type[Model], Tuple[str]] = {
+    FilterList: ("default_settings",),
+    FilterSettings: ("default_action", "default_range"),
+    FilterAction: (),
+    ChannelRange: (),
+    Filter: (),
+    FilterOverride: ("filter_action", "filter_range")
+}
+
+
+def get_test_sequences() -> Dict[str, TestSequence]:
+    return {
+        "filter_list": TestSequence(
+            FilterList,
+            "filterlist",
+            {
+                "name": "testname",
+                "list_type": 0,
+                "default_settings": FilterSettings(
+                    ping_type=[],
+                    filter_dm=False,
+                    dm_ping_type=[],
+                    delete_messages=False,
+                    bypass_roles=[],
+                    enabled=False,
+                    default_action=FilterAction(
+                        user_dm=None,
+                        infraction_type=None,
+                        infraction_reason="",
+                        infraction_duration=None
+                    ),
+                    default_range=ChannelRange(
+                        disallowed_channels=[],
+                        disallowed_categories=[],
+                        allowed_channels=[],
+                        allowed_category=[],
+                        default=False
+                    )
+                )
+            },
+            ignored_fields=("filters",)
+        ),
+        "filter_settings": TestSequence(
+            FilterSettings,
+            "filtersettings",
+            {
+                "ping_type": ["onduty"],
+                "filter_dm": True,
+                "dm_ping_type": ["123456"],
+                "delete_messages": True,
+                "bypass_roles": [123456],
+                "enabled": True,
+                "default_action": FilterAction(
+                    user_dm=None,
+                    infraction_type=None,
+                    infraction_reason="",
+                    infraction_duration=None
+                ),
+                "default_range": ChannelRange(
+                    disallowed_channels=[],
+                    disallowed_categories=[],
+                    allowed_channels=[],
+                    allowed_category=[],
+                    default=False
+                )
+            }
+        ),
+        "filter_action": TestSequence(
+            FilterAction,
+            "filteraction",
+            {
+                "user_dm": "This is a DM message.",
+                "infraction_type": "Mute",
+                "infraction_reason": "Too long beard",
+                "infraction_duration": "1 02:03:00"
+            }
+        ),
+        "channel_range": TestSequence(
+            ChannelRange,
+            "channelrange",
+            {
+                "disallowed_channels": [1234],
+                "disallowed_categories": [5678],
+                "allowed_channels": [9101],
+                "allowed_category": [1121],
+                "default": True
+            }
+        ),
+        "filter": TestSequence(
+            Filter,
+            "filter",
+            {
+                "content": "bad word",
+                "description": "This is a really bad word.",
+                "additional_field": None,
+                "override": None
+            }
+        ),
+        "filter_override": TestSequence(
+            FilterOverride,
+            "filteroverride",
+            {
+                "ping_type": ["everyone"],
+                "filter_dm": False,
+                "dm_ping_type": ["here"],
+                "delete_messages": False,
+                "bypass_roles": [9876],
+                "enabled": True,
+                "filter_action": None,
+                "filter_range": None
+            }
+        )
+    }
+
+
+def save_nested_objects(object_: Model, save_root: bool = True) -> None:
+    for field in FK_FIELDS[object_.__class__]:
+        value = getattr(object_, field)
+
+        if value is not None:
+            save_nested_objects(value)
+
+    if save_root:
+        object_.save()
+
+
+def clean_test_json(json: dict) -> dict:
+    for key, value in json.items():
+        if isinstance(value, Model):
+            json[key] = value.id
+
+    return json
+
+
+def clean_api_json(json: dict, sequence: TestSequence) -> dict:
+    for field in sequence.ignored_fields + ("id",):
+        with contextlib.suppress(KeyError):
+            del json[field]
+
+    return json
+
+
+class GenericFilterTest(APISubdomainTestCase):
+    def test_cannot_read_unauthenticated(self) -> None:
+        for name, sequence in get_test_sequences().items():
+            with self.subTest(name=name):
+                self.client.force_authenticate(user=None)
+
+                response = self.client.get(sequence.url())
+                self.assertEqual(response.status_code, 401)
+
+    def test_empty_database(self) -> None:
+        for name, sequence in get_test_sequences().items():
+            with self.subTest(name=name):
+                sequence.model.objects.all().delete()
+
+                response = self.client.get(sequence.url())
+                self.assertEqual(response.status_code, 200)
+                self.assertEqual(response.json(), [])
+
+    def test_fetch(self) -> None:
+        for name, sequence in get_test_sequences().items():
+            with self.subTest(name=name):
+                sequence.model.objects.all().delete()
+
+                save_nested_objects(sequence.model(**sequence.object))
+
+                response = self.client.get(sequence.url())
+                self.assertDictEqual(
+                    clean_test_json(sequence.object),
+                    clean_api_json(response.json()[0], sequence)
+                )
+
+    def test_fetch_by_id(self) -> None:
+        for name, sequence in get_test_sequences().items():
+            with self.subTest(name=name):
+                sequence.model.objects.all().delete()
+
+                saved = sequence.model(**sequence.object)
+                save_nested_objects(saved)
+
+                response = self.client.get(f"{sequence.url()}/{saved.id}")
+                self.assertDictEqual(
+                    clean_test_json(sequence.object),
+                    clean_api_json(response.json(), sequence)
+                )
+
+    def test_fetch_non_existing(self) -> None:
+        for name, sequence in get_test_sequences().items():
+            with self.subTest(name=name):
+                sequence.model.objects.all().delete()
+
+                response = self.client.get(f"{sequence.url()}/42")
+                self.assertEqual(response.status_code, 404)
+                self.assertDictEqual(response.json(), {'detail': 'Not found.'})
+
+    def test_creation(self) -> None:
+        for name, sequence in get_test_sequences().items():
+            with self.subTest(name=name):
+                sequence.model.objects.all().delete()
+
+                save_nested_objects(sequence.model(**sequence.object), False)
+                data = clean_test_json(sequence.object.copy())
+                response = self.client.post(sequence.url(), data=data)
+
+                self.assertEqual(response.status_code, 201)
+                self.assertDictEqual(
+                    clean_api_json(response.json(), sequence),
+                    clean_test_json(sequence.object)
+                )
+
+    def test_creation_missing_field(self) -> None:
+        for name, sequence in get_test_sequences().items():
+            with self.subTest(name=name):
+                save_nested_objects(sequence.model(**sequence.object), False)
+                data = clean_test_json(sequence.object.copy())
+
+                for field in sequence.model._meta.get_fields():
+                    with self.subTest(field=field):
+                        if field.null or field.name in sequence.ignored_fields + ("id",):
+                            continue
+
+                        test_data = data.copy()
+                        del test_data[field.name]
+
+                        response = self.client.post(sequence.url(), data=test_data)
+                        self.assertEqual(response.status_code, 400)
+
+    def test_deletion(self) -> None:
+        for name, sequence in get_test_sequences().items():
+            with self.subTest(name=name):
+                saved = sequence.model(**sequence.object)
+                save_nested_objects(saved)
+
+                response = self.client.delete(f"{sequence.url()}/{saved.id}")
+                self.assertEqual(response.status_code, 204)
+
+    def test_deletion_non_existing(self) -> None:
+        for name, sequence in get_test_sequences().items():
+            with self.subTest(name=name):
+                sequence.model.objects.all().delete()
+
+                response = self.client.delete(f"{sequence.url()}/42")
+                self.assertEqual(response.status_code, 404)
+
+    def test_reject_invalid_ping(self) -> None:
+        url = reverse('bot:filteroverride-list', host='api')
+        data = {
+            "ping_type": ["invalid"]
+        }
+
+        response = self.client.post(url, data=data)
+
+        self.assertEqual(response.status_code, 400)
+        self.assertDictEqual(response.json(), {'ping_type': ["'invalid' isn't a valid ping type."]})
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index 5c9ddea4..c8f4e1b1 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -7,6 +7,9 @@ from django.utils import timezone
 from pydis_site.apps.api.models import (
     DeletedMessage,
     DocumentationLink,
+    Filter,
+    FilterList,
+    FilterSettings,
     Infraction,
     Message,
     MessageDeletionContext,
@@ -106,6 +109,17 @@ class StringDunderMethodTests(SimpleTestCase):
             DocumentationLink(
                 'test', 'http://example.com', 'http://example.com'
             ),
+            FilterList(
+                name="forbidden_duckies",
+                list_type=0,
+                default_settings=FilterSettings()
+            ),
+            Filter(
+                content="ducky_nsfw",
+                description="This ducky is totally inappropriate!",
+                additional_field=None,
+                override=None
+            ),
             OffensiveMessage(
                 id=602951077675139072,
                 channel_id=291284109232308226,
-- 
cgit v1.2.3
From 71a5e0d854c587ca2ae70aaec80f1110ea8800e5 Mon Sep 17 00:00:00 2001
From: Matteo Bertucci 
Date: Tue, 27 Apr 2021 16:41:18 +0200
Subject: Filters: allowed_category -> allowed_categories
---
 pydis_site/apps/api/migrations/0070_new_filter_schema.py | 4 ++--
 pydis_site/apps/api/models/bot/filters.py                | 2 +-
 pydis_site/apps/api/serializers.py                       | 2 +-
 pydis_site/apps/api/tests/test_filters.py                | 6 +++---
 pydis_site/apps/api/viewsets/bot/filters.py              | 8 ++++----
 5 files changed, 11 insertions(+), 11 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index de75e677..eb55e329 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -33,7 +33,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
             disallowed_channels=[],
             disallowed_categories=[],
             allowed_channels=[],
-            allowed_category=[],
+            allowed_categories=[],
             default=True
         )
         default_range.save()
@@ -85,7 +85,7 @@ class Migration(migrations.Migration):
                 ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
                 ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
                 ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('allowed_category', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
                 ('default', models.BooleanField()),
             ],
         ),
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 869f947c..c2f776d3 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -142,7 +142,7 @@ class ChannelRange(models.Model):
     disallowed_channels = ArrayField(models.IntegerField())
     disallowed_categories = ArrayField(models.IntegerField())
     allowed_channels = ArrayField(models.IntegerField())
-    allowed_category = ArrayField(models.IntegerField())
+    allowed_categories = ArrayField(models.IntegerField())
     default = models.BooleanField()
 
 
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 306dccb3..54acf366 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -180,7 +180,7 @@ class FilterChannelRangeSerializer(ModelSerializer):
             'disallowed_channels',
             'disallowed_categories',
             'allowed_channels',
-            'allowed_category',
+            'allowed_categories',
             'default'
         )
 
diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py
index de78ecfd..f38f3659 100644
--- a/pydis_site/apps/api/tests/test_filters.py
+++ b/pydis_site/apps/api/tests/test_filters.py
@@ -62,7 +62,7 @@ def get_test_sequences() -> Dict[str, TestSequence]:
                         disallowed_channels=[],
                         disallowed_categories=[],
                         allowed_channels=[],
-                        allowed_category=[],
+                        allowed_categories=[],
                         default=False
                     )
                 )
@@ -89,7 +89,7 @@ def get_test_sequences() -> Dict[str, TestSequence]:
                     disallowed_channels=[],
                     disallowed_categories=[],
                     allowed_channels=[],
-                    allowed_category=[],
+                    allowed_categories=[],
                     default=False
                 )
             }
@@ -111,7 +111,7 @@ def get_test_sequences() -> Dict[str, TestSequence]:
                 "disallowed_channels": [1234],
                 "disallowed_categories": [5678],
                 "allowed_channels": [9101],
-                "allowed_category": [1121],
+                "allowed_categories": [1121],
                 "default": True
             }
         ),
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
index fea53265..e290fc65 100644
--- a/pydis_site/apps/api/viewsets/bot/filters.py
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -350,7 +350,7 @@ class FilterChannelRangeViewSet(ModelViewSet):
     ...         "disallowed_channels": [],
     ...         "disallowed_categories": [],
     ...         "allowed_channels": [],
-    ...         "allowed_category": [],
+    ...         "allowed_categories": [],
     ...         "default": True
     ...     },
     ...     ...
@@ -369,7 +369,7 @@ class FilterChannelRangeViewSet(ModelViewSet):
     ...     "disallowed_channels": [],
     ...     "disallowed_categories": [],
     ...     "allowed_channels": [],
-    ...     "allowed_category": [],
+    ...     "allowed_categories": [],
     ...     "default": True
     ... }
 
@@ -385,7 +385,7 @@ class FilterChannelRangeViewSet(ModelViewSet):
     ...     "disallowed_channels": [],
     ...     "disallowed_categories": [],
     ...     "allowed_channels": [],
-    ...     "allowed_category": [],
+    ...     "allowed_categories": [],
     ...     "default": True
     ... }
 
@@ -402,7 +402,7 @@ class FilterChannelRangeViewSet(ModelViewSet):
     ...     "disallowed_channels": [],
     ...     "disallowed_categories": [],
     ...     "allowed_channels": [],
-    ...     "allowed_category": [],
+    ...     "allowed_categories": [],
     ...     "default": True
     ... }
 
-- 
cgit v1.2.3
From d48d5ddbaa6b068d3a24f55ee7c8f3760006f04b Mon Sep 17 00:00:00 2001
From: kosayoda 
Date: Sun, 11 Jul 2021 14:46:16 +0800
Subject: Improve help text message.
---
 pydis_site/apps/api/models/bot/filters.py | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index c2f776d3..a2d3af6a 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -46,7 +46,7 @@ class FilterList(models.Model):
     name = models.CharField(max_length=50, help_text="The unique name of this list.")
     list_type = models.IntegerField(
         choices=FilterListType.choices,
-        help_text="Whenever this list is an allowlist or denylist"
+        help_text="Whether this list is an allowlist or denylist"
     )
 
     filters = models.ManyToManyField("Filter", help_text="The content of this list.", default=[])
@@ -75,31 +75,31 @@ class FilterSettings(models.Model):
         validators=(validate_ping_field,),
         help_text="Who to ping when this filter triggers."
     )
-    filter_dm = models.BooleanField(help_text="Whenever DMs should be filtered.")
+    filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.")
     dm_ping_type = ArrayField(
         models.CharField(max_length=20),
         validators=(validate_ping_field,),
         help_text="Who to ping when this filter triggers on a DM."
     )
     delete_messages = models.BooleanField(
-        help_text="Whenever this filter should delete messages triggering it."
+        help_text="Whether this filter should delete messages triggering it."
     )
     bypass_roles = ArrayField(
         models.BigIntegerField(),
         help_text="Roles and users who can bypass this filter."
     )
     enabled = models.BooleanField(
-        help_text="Whenever ths filter is currently enabled."
+        help_text="Whether this filter is currently enabled."
     )
     default_action = models.ForeignKey(
         "FilterAction",
         models.CASCADE,
-        help_text="The default action to perform."
+        help_text="What action to perform on the triggering user."
     )
     default_range = models.ForeignKey(
         "ChannelRange",
         models.CASCADE,
-        help_text="Where does this filter apply."
+        help_text="The channels and categories in which this filter applies."
     )
 
 
-- 
cgit v1.2.3
From 6694ac4159c6d0f17451997df7f20b1363952ef3 Mon Sep 17 00:00:00 2001
From: kosayoda 
Date: Sun, 11 Jul 2021 15:58:32 +0800
Subject: Fix faulty model enumeration.
This also allows us to simplify the str dunder for a FilterList.
---
 pydis_site/apps/api/models/bot/filters.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index a2d3af6a..6f35bfb0 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -9,8 +9,8 @@ from django.db.models import UniqueConstraint
 class FilterListType(models.IntegerChoices):
     """Choice between allow or deny for a list type."""
 
-    ALLOW: 1
-    DENY: 0
+    ALLOW = 1
+    DENY = 0
 
 
 class InfractionType(models.TextChoices):
@@ -64,7 +64,7 @@ class FilterList(models.Model):
         )
 
     def __str__(self) -> str:
-        return f"Filter {'allow' if self.list_type == 1 else 'deny'}list {self.name!r}"
+        return f"Filter {FilterListType(self.list_type).label}list {self.name!r}"
 
 
 class FilterSettings(models.Model):
-- 
cgit v1.2.3
From 1095346a1f86e43d5d5c39045a54354d1290fe0e Mon Sep 17 00:00:00 2001
From: kosayoda 
Date: Sun, 11 Jul 2021 16:35:41 +0800
Subject: Improve name of dm sent to triggered user.
---
 pydis_site/apps/api/migrations/0070_new_filter_schema.py | 4 ++--
 pydis_site/apps/api/models/bot/filters.py                | 2 +-
 pydis_site/apps/api/serializers.py                       | 2 +-
 pydis_site/apps/api/tests/test_filters.py                | 6 +++---
 pydis_site/apps/api/viewsets/bot/filters.py              | 8 ++++----
 5 files changed, 11 insertions(+), 11 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index eb55e329..8580033a 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -23,7 +23,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
         objects = filter_list_old.objects.filter(type=name)
 
         default_action = filter_action.objects.create(
-            user_dm=None,
+            dm_content=None,
             infraction_type=None,
             infraction_reason="",
             infraction_duration=None
@@ -102,7 +102,7 @@ class Migration(migrations.Migration):
             name='FilterAction',
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('user_dm', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
+                ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
                 ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)),
                 ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)),
                 ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 6f35bfb0..b5c80bda 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -106,7 +106,7 @@ class FilterSettings(models.Model):
 class FilterAction(models.Model):
     """The action to take when a filter is triggered."""
 
-    user_dm = models.CharField(
+    dm_content = models.CharField(
         max_length=1000,
         null=True,
         help_text="The DM to send to a user triggering this filter."
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 54acf366..584d1f22 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -165,7 +165,7 @@ class FilterActionSerializer(ModelSerializer):
         """Metadata defined for the Django REST Framework."""
 
         model = FilterAction
-        fields = ('id', 'user_dm', 'infraction_type', 'infraction_reason', 'infraction_duration')
+        fields = ('id', 'dm_content', 'infraction_type', 'infraction_reason', 'infraction_duration')
 
 
 class FilterChannelRangeSerializer(ModelSerializer):
diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py
index f38f3659..2df671e0 100644
--- a/pydis_site/apps/api/tests/test_filters.py
+++ b/pydis_site/apps/api/tests/test_filters.py
@@ -53,7 +53,7 @@ def get_test_sequences() -> Dict[str, TestSequence]:
                     bypass_roles=[],
                     enabled=False,
                     default_action=FilterAction(
-                        user_dm=None,
+                        dm_content=None,
                         infraction_type=None,
                         infraction_reason="",
                         infraction_duration=None
@@ -80,7 +80,7 @@ def get_test_sequences() -> Dict[str, TestSequence]:
                 "bypass_roles": [123456],
                 "enabled": True,
                 "default_action": FilterAction(
-                    user_dm=None,
+                    dm_content=None,
                     infraction_type=None,
                     infraction_reason="",
                     infraction_duration=None
@@ -98,7 +98,7 @@ def get_test_sequences() -> Dict[str, TestSequence]:
             FilterAction,
             "filteraction",
             {
-                "user_dm": "This is a DM message.",
+                "dm_content": "This is a DM message.",
                 "infraction_type": "Mute",
                 "infraction_reason": "Too long beard",
                 "infraction_duration": "1 02:03:00"
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
index e290fc65..9553fcac 100644
--- a/pydis_site/apps/api/viewsets/bot/filters.py
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -264,7 +264,7 @@ class FilterActionViewSet(ModelViewSet):
     >>> [
     ...     {
     ...         "id": 1,
-    ...         "user_dm": "message",
+    ...         "dm_content": "message",
     ...         "infraction_type": "Warn",
     ...         "infraction_reason": "",
     ...         "infraction_duration": "01 12:34:56.123456"
@@ -282,7 +282,7 @@ class FilterActionViewSet(ModelViewSet):
     #### Response format
     >>> {
     ...     "id": 1,
-    ...     "user_dm": "message",
+    ...     "dm_content": "message",
     ...     "infraction_type": "Warn",
     ...     "infraction_reason": "",
     ...     "infraction_duration": "01 12:34:56.123456"
@@ -297,7 +297,7 @@ class FilterActionViewSet(ModelViewSet):
 
     #### Request body
     >>> {
-    ...     "user_dm": "message",
+    ...     "dm_content": "message",
     ...     "infraction_type": "Warn",
     ...     "infraction_reason": "",
     ...     "infraction_duration": "01 12:34:56.123456"
@@ -313,7 +313,7 @@ class FilterActionViewSet(ModelViewSet):
     #### Response format
     >>> {
     ...     "id": 1,
-    ...     "user_dm": "message",
+    ...     "dm_content": "message",
     ...     "infraction_type": "Warn",
     ...     "infraction_reason": "",
     ...     "infraction_duration": "01 12:34:56.123456"
-- 
cgit v1.2.3
From 9ec355955895d5b26ce99aade3c0c6ccf913e6a4 Mon Sep 17 00:00:00 2001
From: kosayoda 
Date: Sun, 11 Jul 2021 16:40:34 +0800
Subject: Migrate misc field names and help text changes.
---
 .../apps/api/migrations/0071_auto_20210711_0839.py | 44 ++++++++++++++++++++++
 1 file changed, 44 insertions(+)
 create mode 100644 pydis_site/apps/api/migrations/0071_auto_20210711_0839.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py b/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py
new file mode 100644
index 00000000..e1c45fb6
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.0.14 on 2021-07-11 08:39
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0070_new_filter_schema'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='filterlist',
+            name='list_type',
+            field=models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist'),
+        ),
+        migrations.AlterField(
+            model_name='filtersettings',
+            name='default_action',
+            field=models.ForeignKey(help_text='What action to perform on the triggering user.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction'),
+        ),
+        migrations.AlterField(
+            model_name='filtersettings',
+            name='default_range',
+            field=models.ForeignKey(help_text='The channels and categories in which this filter applies.', on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange'),
+        ),
+        migrations.AlterField(
+            model_name='filtersettings',
+            name='delete_messages',
+            field=models.BooleanField(help_text='Whether this filter should delete messages triggering it.'),
+        ),
+        migrations.AlterField(
+            model_name='filtersettings',
+            name='enabled',
+            field=models.BooleanField(help_text='Whether this filter is currently enabled.'),
+        ),
+        migrations.AlterField(
+            model_name='filtersettings',
+            name='filter_dm',
+            field=models.BooleanField(help_text='Whether DMs should be filtered.'),
+        ),
+    ]
-- 
cgit v1.2.3
From b082de6662e1b57f6831d219b44d95f93ed8a884 Mon Sep 17 00:00:00 2001
From: kosayoda 
Date: Fri, 23 Jul 2021 18:58:35 +0800
Subject: Correct Filter-FilterList relationship.
Instead of a many-many relationship, one filterlist has multiple
filters. Nested serialization is read-only by default, so not
all CRUD methods are implemented yet for the FilterList viewset.
---
 .../apps/api/migrations/0070_new_filter_schema.py  | 11 ++--
 pydis_site/apps/api/models/bot/filters.py          |  6 +-
 pydis_site/apps/api/serializers.py                 | 22 ++++----
 pydis_site/apps/api/tests/test_filters.py          | 29 +++++++++-
 pydis_site/apps/api/viewsets/bot/filters.py        | 64 +++++++---------------
 5 files changed, 68 insertions(+), 64 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index 8580033a..237ce7d7 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -54,17 +54,14 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
             list_type=1 if type_ == "ALLOW" else 0
         )
 
-        new_objects = []
         for object_ in objects:
             new_object = filter_.objects.create(
                 content=object_.content,
+                filter_list = list_,
                 description=object_.comment or "",
                 additional_field=None, override=None
             )
             new_object.save()
-            new_objects.append(new_object)
-
-        list_.filters.add(*new_objects)
 
 
 class Migration(migrations.Migration):
@@ -143,7 +140,6 @@ class Migration(migrations.Migration):
                 ('name', models.CharField(help_text='The unique name of this list.', max_length=50)),
                 ('list_type', models.IntegerField(choices=[], help_text='Whenever this list is an allowlist or denylist')),
                 ('default_settings', models.ForeignKey(help_text='Default parameters of this list.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterSettings')),
-                ('filters', models.ManyToManyField(help_text='The content of this list.', to='api.Filter', default=[])),
             ],
         ),
         migrations.AddField(
@@ -151,6 +147,11 @@ class Migration(migrations.Migration):
             name='override',
             field=models.ForeignKey(help_text='Override the default settings.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.FilterOverride'),
         ),
+        migrations.AddField(
+            model_name='filter',
+            name='filter_list',
+            field=models.ForeignKey(help_text='The filter list containing this filter.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='api.FilterList'),
+        ),
         migrations.AddConstraint(
             model_name='filterlist',
             constraint=models.UniqueConstraint(fields=('name', 'list_type'), name='unique_name_type'),
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index b5c80bda..99d6d5e4 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -48,8 +48,6 @@ class FilterList(models.Model):
         choices=FilterListType.choices,
         help_text="Whether this list is an allowlist or denylist"
     )
-
-    filters = models.ManyToManyField("Filter", help_text="The content of this list.", default=[])
     default_settings = models.ForeignKey(
         "FilterSettings",
         models.CASCADE,
@@ -152,6 +150,10 @@ class Filter(models.Model):
     content = models.CharField(max_length=100, help_text="The definition of this filter.")
     description = models.CharField(max_length=200, help_text="Why this filter has been added.")
     additional_field = models.BooleanField(null=True, help_text="Implementation specific field.")
+    filter_list = models.ForeignKey(
+        FilterList, models.CASCADE, related_name="filters",
+        help_text="The filter list containing this filter."
+    )
     override = models.ForeignKey(
         "FilterOverride",
         models.SET_NULL,
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 584d1f22..afcf4d55 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -117,9 +117,21 @@ class DocumentationLinkSerializer(ModelSerializer):
         fields = ('package', 'base_url', 'inventory_url')
 
 
+class FilterSerializer(ModelSerializer):
+    """A class providing (de-)serialization of `Filter` instances."""
+
+    class Meta:
+        """Metadata defined for the Django REST Framework."""
+
+        model = Filter
+        fields = ('id', 'content', 'description', 'additional_field', 'filter_list', 'override')
+
+
 class FilterListSerializer(ModelSerializer):
     """A class providing (de-)serialization of `FilterList` instances."""
 
+    filters = FilterSerializer(many=True, read_only=True)
+
     class Meta:
         """Metadata defined for the Django REST Framework."""
 
@@ -185,16 +197,6 @@ class FilterChannelRangeSerializer(ModelSerializer):
         )
 
 
-class FilterSerializer(ModelSerializer):
-    """A class providing (de-)serialization of `Filter` instances."""
-
-    class Meta:
-        """Metadata defined for the Django REST Framework."""
-
-        model = Filter
-        fields = ('id', 'content', 'description', 'additional_field', 'override')
-
-
 class FilterOverrideSerializer(ModelSerializer):
     """A class providing (de-)serialization of `FilterOverride` instances."""
 
diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py
index 2df671e0..f694053d 100644
--- a/pydis_site/apps/api/tests/test_filters.py
+++ b/pydis_site/apps/api/tests/test_filters.py
@@ -32,7 +32,7 @@ FK_FIELDS: Dict[Type[Model], Tuple[str]] = {
     FilterSettings: ("default_action", "default_range"),
     FilterAction: (),
     ChannelRange: (),
-    Filter: (),
+    Filter: ("filter_list",),
     FilterOverride: ("filter_action", "filter_range")
 }
 
@@ -122,7 +122,32 @@ def get_test_sequences() -> Dict[str, TestSequence]:
                 "content": "bad word",
                 "description": "This is a really bad word.",
                 "additional_field": None,
-                "override": None
+                "override": None,
+                "filter_list": FilterList(
+                    name="testname",
+                    list_type=0,
+                    default_settings=FilterSettings(
+                        ping_type=[],
+                        filter_dm=False,
+                        dm_ping_type=[],
+                        delete_messages=False,
+                        bypass_roles=[],
+                        enabled=False,
+                        default_action=FilterAction(
+                            dm_content=None,
+                            infraction_type=None,
+                            infraction_reason="",
+                            infraction_duration=None
+                        ),
+                        default_range=ChannelRange(
+                            disallowed_channels=[],
+                            disallowed_categories=[],
+                            allowed_channels=[],
+                            allowed_categories=[],
+                            default=False
+                        )
+                    )
+                )
             }
         ),
         "filter_override": TestSequence(
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
index 9553fcac..1b893f8c 100644
--- a/pydis_site/apps/api/viewsets/bot/filters.py
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -20,7 +20,7 @@ from pydis_site.apps.api.serializers import (  # noqa: I101 - Preserving the fil
 
 class FilterListViewSet(ModelViewSet):
     """
-    View providing CRUD operations on lists of items allowed or denied by our bot.
+    View providing GET/DELETE on lists of items allowed or denied by our bot.
 
     ## Routes
     ### GET /bot/filter/filter_lists
@@ -33,8 +33,14 @@ class FilterListViewSet(ModelViewSet):
     ...         "name": "guild_invite",
     ...         "list_type": 1,
     ...         "filters": [
-    ...             1,
-    ...             2,
+    ...             {
+    ...                 "id": 1,
+    ...                 "content": "267624335836053506",
+    ...                 "description": "Python Discord",
+    ...                 "additional_field": None,
+    ...                 "override": 1,
+    ...                 "filter_list": 1
+    ...             },
     ...             ...
     ...         ],
     ...         "default_settings": 1
@@ -55,8 +61,14 @@ class FilterListViewSet(ModelViewSet):
     ...     "name": "guild_invite",
     ...     "list_type": 1,
     ...     "filters": [
-    ...         1,
-    ...         2,
+    ...         {
+    ...             "id": 1,
+    ...             "content": "267624335836053506",
+    ...             "description": "Python Discord",
+    ...             "additional_field": None,
+    ...             "override": 1,
+    ...             "filter_list": 1
+    ...         },
     ...         ...
     ...     ],
     ...     "default_settings": 1
@@ -66,45 +78,6 @@ class FilterListViewSet(ModelViewSet):
     - 200: returned on success
     - 404: returned if the id was not found.
 
-    ### POST /bot/filter/filter_lists
-    Adds a single FilterList item to the database.
-
-    #### Request body
-    >>> {
-    ...     "name": "guild_invite",
-    ...     "list_type": 1,
-    ...     "filters": [
-    ...         1,
-    ...         2,
-    ...         ...
-    ...     ],
-    ...     "default_settings": 1
-    ... }
-
-    #### Status codes
-    - 201: returned on success
-    - 400: if one of the given fields is invalid
-
-    ### PATCH /bot/filter/filter_lists/
-    Updates a specific FilterList item from the database.
-
-    #### Response format
-    >>> {
-    ...     "id": 1,
-    ...     "name": "guild_invite",
-    ...     "list_type": 1,
-    ...     "filters": [
-    ...         1,
-    ...         2,
-    ...         ...
-    ...     ],
-    ...     "default_settings": 1
-    ... }
-
-    #### Status codes
-    - 200: returned on success
-    - 400: if one of the given fields is invalid
-
     ### DELETE /bot/filter/filter_lists/
     Deletes the FilterList item with the given `id`.
 
@@ -437,7 +410,8 @@ class FilterViewSet(ModelViewSet):
     ...         "content": "267624335836053506",
     ...         "description": "Python Discord",
     ...         "additional_field": None,
-    ...         "override": 1
+    ...         "override": 1,
+    ...         "filter_list": 1
     ...     },
     ...     ...
     ... ]
-- 
cgit v1.2.3
From 98d36f6fce899680fa10177556f06cc5357eb675 Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Sat, 25 Sep 2021 13:04:39 +0300
Subject: Remove one-to-one relationships from filters tables
---
 pydis_site/apps/api/models/bot/filters.py | 153 ++++++++++++------------------
 1 file changed, 63 insertions(+), 90 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 99d6d5e4..68ac191b 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -1,3 +1,4 @@
+from abc import abstractmethod
 from typing import List
 
 from django.contrib.postgres.fields import ArrayField
@@ -5,6 +6,8 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.db.models import UniqueConstraint
 
+from pydis_site.apps.api.models.mixins import AbstractModelMeta
+
 
 class FilterListType(models.IntegerChoices):
     """Choice between allow or deny for a list type."""
@@ -40,70 +43,40 @@ def validate_ping_field(value_list: List[str]) -> None:
         raise ValidationError(f"{value!r} isn't a valid ping type.")
 
 
-class FilterList(models.Model):
-    """Represent a list in its allow or deny form."""
-
-    name = models.CharField(max_length=50, help_text="The unique name of this list.")
-    list_type = models.IntegerField(
-        choices=FilterListType.choices,
-        help_text="Whether this list is an allowlist or denylist"
-    )
-    default_settings = models.ForeignKey(
-        "FilterSettings",
-        models.CASCADE,
-        help_text="Default parameters of this list."
-    )
-
-    class Meta:
-        """Constrain name and list_type unique."""
-
-        constraints = (
-            UniqueConstraint(fields=("name", "list_type"), name="unique_name_type"),
-        )
-
-    def __str__(self) -> str:
-        return f"Filter {FilterListType(self.list_type).label}list {self.name!r}"
-
+class FilterSettingsMixin(models.Model, metaclass=AbstractModelMeta):
+    """Mixin for settings of a filter list."""
 
-class FilterSettings(models.Model):
-    """Persistent settings of a filter list."""
+    @staticmethod
+    @abstractmethod
+    def allow_null() -> bool:
+        """Abstract property for allowing null values."""
 
     ping_type = ArrayField(
         models.CharField(max_length=20),
         validators=(validate_ping_field,),
-        help_text="Who to ping when this filter triggers."
+        help_text="Who to ping when this filter triggers.",
+        null=allow_null.__func__()
     )
-    filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.")
+    filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True)
     dm_ping_type = ArrayField(
         models.CharField(max_length=20),
         validators=(validate_ping_field,),
-        help_text="Who to ping when this filter triggers on a DM."
+        help_text="Who to ping when this filter triggers on a DM.",
+        null=allow_null.__func__()
     )
     delete_messages = models.BooleanField(
-        help_text="Whether this filter should delete messages triggering it."
+        help_text="Whether this filter should delete messages triggering it.",
+        null=allow_null.__func__()
     )
     bypass_roles = ArrayField(
         models.BigIntegerField(),
-        help_text="Roles and users who can bypass this filter."
+        help_text="Roles and users who can bypass this filter.",
+        null=allow_null.__func__()
     )
     enabled = models.BooleanField(
-        help_text="Whether this filter is currently enabled."
+        help_text="Whether this filter is currently enabled.",
+        null=allow_null.__func__()
     )
-    default_action = models.ForeignKey(
-        "FilterAction",
-        models.CASCADE,
-        help_text="What action to perform on the triggering user."
-    )
-    default_range = models.ForeignKey(
-        "ChannelRange",
-        models.CASCADE,
-        help_text="The channels and categories in which this filter applies."
-    )
-
-
-class FilterAction(models.Model):
-    """The action to take when a filter is triggered."""
-
     dm_content = models.CharField(
         max_length=1000,
         null=True,
@@ -124,27 +97,52 @@ class FilterAction(models.Model):
         help_text="The duration of the infraction. Null if permanent."
     )
 
-
-class ChannelRange(models.Model):
-    """
-    Where a filter should apply.
-
-    The resolution is done in the following order:
-    - disallowed channels
-    - disallowed categories
-    - allowed categories
-    - allowed channels
-    - default
-    """
-
+    # Where a filter should apply.
+    #
+    # The resolution is done in the following order:
+    #   - disallowed channels
+    #   - disallowed categories
+    #   - allowed categories
+    #   - allowed channels
+    #   - default
     disallowed_channels = ArrayField(models.IntegerField())
     disallowed_categories = ArrayField(models.IntegerField())
     allowed_channels = ArrayField(models.IntegerField())
     allowed_categories = ArrayField(models.IntegerField())
     default = models.BooleanField()
 
+    class Meta:
+        """Metaclass for settings mixin."""
+
+        abstract = True
 
-class Filter(models.Model):
+
+class FilterList(FilterSettingsMixin):
+    """Represent a list in its allow or deny form."""
+
+    name = models.CharField(max_length=50, help_text="The unique name of this list.")
+    list_type = models.IntegerField(
+        choices=FilterListType.choices,
+        help_text="Whether this list is an allowlist or denylist"
+    )
+
+    @staticmethod
+    def allow_null() -> bool:
+        """Do not allow null values for default settings."""
+        return False
+
+    class Meta:
+        """Constrain name and list_type unique."""
+
+        constraints = (
+            UniqueConstraint(fields=("name", "list_type"), name="unique_name_type"),
+        )
+
+    def __str__(self) -> str:
+        return f"Filter {FilterListType(self.list_type).label}list {self.name!r}"
+
+
+class Filter(FilterSettingsMixin):
     """One specific trigger of a list."""
 
     content = models.CharField(max_length=100, help_text="The definition of this filter.")
@@ -154,36 +152,11 @@ class Filter(models.Model):
         FilterList, models.CASCADE, related_name="filters",
         help_text="The filter list containing this filter."
     )
-    override = models.ForeignKey(
-        "FilterOverride",
-        models.SET_NULL,
-        null=True,
-        help_text="Override the default settings."
-    )
 
     def __str__(self) -> str:
         return f"Filter {self.content!r}"
 
-
-class FilterOverride(models.Model):
-    """
-    Setting overrides of a specific filter.
-
-    Any non-null value will override the default ones.
-    """
-
-    ping_type = ArrayField(
-        models.CharField(max_length=20),
-        validators=(validate_ping_field,), null=True
-    )
-    filter_dm = models.BooleanField(null=True)
-    dm_ping_type = ArrayField(
-        models.CharField(max_length=20),
-        validators=(validate_ping_field,),
-        null=True
-    )
-    delete_messages = models.BooleanField(null=True)
-    bypass_roles = ArrayField(models.IntegerField(), null=True)
-    enabled = models.BooleanField(null=True)
-    filter_action = models.ForeignKey("FilterAction", models.CASCADE, null=True)
-    filter_range = models.ForeignKey("ChannelRange", models.CASCADE, null=True)
+    @staticmethod
+    def allow_null() -> bool:
+        """Allow null values for overrides."""
+        return True
-- 
cgit v1.2.3
From 08a52168dd3b0a9a366f5ca68c10437b83af5cf1 Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Sat, 25 Sep 2021 13:05:52 +0300
Subject: Remove old one-to-one filters relationships serializers, views and
 URLs
---
 pydis_site/apps/api/models/__init__.py       |   4 -
 pydis_site/apps/api/models/bot/__init__.py   |   2 +-
 pydis_site/apps/api/serializers.py           |  84 +----
 pydis_site/apps/api/urls.py                  |  20 --
 pydis_site/apps/api/viewsets/__init__.py     |   4 -
 pydis_site/apps/api/viewsets/bot/__init__.py |   6 +-
 pydis_site/apps/api/viewsets/bot/filters.py  | 450 +--------------------------
 7 files changed, 15 insertions(+), 555 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index 72f59b57..63087990 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -1,11 +1,7 @@
 # flake8: noqa
 from .bot import (
     FilterList,
-    FilterSettings,
-    FilterAction,
-    ChannelRange,
     Filter,
-    FilterOverride,
     BotSetting,
     DocumentationLink,
     DeletedMessage,
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index 1bfe0063..9ba763a4 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -1,5 +1,5 @@
 # flake8: noqa
-from .filters import FilterList, FilterSettings, FilterAction, ChannelRange, Filter, FilterOverride
+from .filters import FilterList, Filter
 from .bot_setting import BotSetting
 from .deleted_message import DeletedMessage
 from .documentation_link import DocumentationLink
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index afcf4d55..ff2bd929 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -18,11 +18,7 @@ from .models import (  # noqa: I101 - Preserving the filter order
     DocumentationLink,
     Infraction,
     FilterList,
-    FilterSettings,
-    FilterAction,
-    ChannelRange,
     Filter,
-    FilterOverride,
     MessageDeletionContext,
     Nomination,
     NominationEntry,
@@ -136,7 +132,18 @@ class FilterListSerializer(ModelSerializer):
         """Metadata defined for the Django REST Framework."""
 
         model = FilterList
-        fields = ('id', 'name', 'list_type', 'filters', 'default_settings')
+        fields = (
+            'id',
+            'name',
+            'list_type',
+            'filters',
+            'ping_type',
+            'filter_dm',
+            'dm_ping_type',
+            'delete_messages',
+            'bypass_roles',
+            ''
+        )
 
         # Ensure that we can only have one filter list with the same name and field
         validators = [
@@ -150,73 +157,6 @@ class FilterListSerializer(ModelSerializer):
         ]
 
 
-class FilterSettingsSerializer(ModelSerializer):
-    """A class providing (de-)serialization of `FilterSettings` instances."""
-
-    class Meta:
-        """Metadata defined for the Django REST Framework."""
-
-        model = FilterSettings
-        fields = (
-            'id',
-            'ping_type',
-            'filter_dm',
-            'dm_ping_type',
-            'delete_messages',
-            'bypass_roles',
-            'enabled',
-            'default_action',
-            'default_range'
-        )
-
-
-class FilterActionSerializer(ModelSerializer):
-    """A class providing (de-)serialization of `FilterAction` instances."""
-
-    class Meta:
-        """Metadata defined for the Django REST Framework."""
-
-        model = FilterAction
-        fields = ('id', 'dm_content', 'infraction_type', 'infraction_reason', 'infraction_duration')
-
-
-class FilterChannelRangeSerializer(ModelSerializer):
-    """A class providing (de-)serialization of `ChannelRange` instances."""
-
-    class Meta:
-        """Metadata defined for the Django REST Framework."""
-
-        model = ChannelRange
-        fields = (
-            'id',
-            'disallowed_channels',
-            'disallowed_categories',
-            'allowed_channels',
-            'allowed_categories',
-            'default'
-        )
-
-
-class FilterOverrideSerializer(ModelSerializer):
-    """A class providing (de-)serialization of `FilterOverride` instances."""
-
-    class Meta:
-        """Metadata defined for the Django REST Framework."""
-
-        model = FilterOverride
-        fields = (
-            'id',
-            'ping_type',
-            'filter_dm',
-            'dm_ping_type',
-            'delete_messages',
-            'bypass_roles',
-            'enabled',
-            'filter_action',
-            'filter_range'
-        )
-
-
 class InfractionSerializer(ModelSerializer):
     """A class providing (de-)serialization of `Infraction` instances."""
 
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index 7af2e505..4e8edaf0 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -7,11 +7,7 @@ from .viewsets import (  # noqa: I101 - Preserving the filter order
     DeletedMessageViewSet,
     DocumentationLinkViewSet,
     FilterListViewSet,
-    FilterSettingsViewSet,
-    FilterActionViewSet,
-    FilterChannelRangeViewSet,
     FilterViewSet,
-    FilterOverrideViewSet,
     InfractionViewSet,
     NominationViewSet,
     OffTopicChannelNameViewSet,
@@ -27,22 +23,6 @@ bot_router.register(
     'filter/filter_lists',
     FilterListViewSet
 )
-bot_router.register(
-    'filter/filter_settings',
-    FilterSettingsViewSet
-)
-bot_router.register(
-    'filter/filter_action',
-    FilterActionViewSet
-)
-bot_router.register(
-    'filter/channel_range',
-    FilterChannelRangeViewSet
-)
-bot_router.register(
-    'filter/filter_override',
-    FilterOverrideViewSet
-)
 bot_router.register(
     'filter/filters',
     FilterViewSet
diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
index b3992d66..4cf4c655 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -5,11 +5,7 @@ from .bot import (
     DocumentationLinkViewSet,
     InfractionViewSet,
     FilterListViewSet,
-    FilterSettingsViewSet,
-    FilterActionViewSet,
-    FilterChannelRangeViewSet,
     FilterViewSet,
-    FilterOverrideViewSet,
     NominationViewSet,
     OffensiveMessageViewSet,
     OffTopicChannelNameViewSet,
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
index 781624bd..4649fcde 100644
--- a/pydis_site/apps/api/viewsets/bot/__init__.py
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -1,11 +1,7 @@
 # flake8: noqa
 from .filters import (
     FilterListViewSet,
-    FilterSettingsViewSet,
-    FilterActionViewSet,
-    FilterChannelRangeViewSet,
-    FilterViewSet,
-    FilterOverrideViewSet
+    FilterViewSet
 )
 from .bot_setting import BotSettingViewSet
 from .deleted_message import DeletedMessageViewSet
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
index 1b893f8c..5b21de26 100644
--- a/pydis_site/apps/api/viewsets/bot/filters.py
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -2,19 +2,11 @@ from rest_framework.viewsets import ModelViewSet
 
 from pydis_site.apps.api.models.bot.filters import (  # noqa: I101 - Preserving the filter order
     FilterList,
-    FilterSettings,
-    FilterAction,
-    ChannelRange,
-    Filter,
-    FilterOverride
+    Filter
 )
 from pydis_site.apps.api.serializers import (  # noqa: I101 - Preserving the filter order
     FilterListSerializer,
-    FilterSettingsSerializer,
-    FilterActionSerializer,
-    FilterChannelRangeSerializer,
     FilterSerializer,
-    FilterOverrideSerializer
 )
 
 
@@ -90,311 +82,6 @@ class FilterListViewSet(ModelViewSet):
     queryset = FilterList.objects.all()
 
 
-class FilterSettingsViewSet(ModelViewSet):
-    """
-    View providing CRUD operations on settings of items allowed or denied by our bot.
-
-    ## Routes
-    ### GET /bot/filter/filter_settings
-    Returns all FilterSettings items in the database.
-
-    #### Response format
-    >>> [
-    ...     {
-    ...         "id": 1,
-    ...         "ping_type": [
-    ...             "onduty",
-    ...              ...
-    ...          ],
-    ...         "filter_dm": True,
-    ...         "dm_ping_type": [
-    ...             "onduty",
-    ...             ...
-    ...         ],
-    ...         "delete_messages": True,
-    ...         "bypass_roles": [
-    ...             267630620367257601,
-    ...             ...
-    ...         ],
-    ...         "enabled": True,
-    ...         "default_action": 1,
-    ...         "default_range": 1
-    ...     },
-    ...     ...
-    ... ]
-
-    #### Status codes
-    - 200: returned on success
-    - 401: returned if unauthenticated
-
-    ### GET /bot/filter/filter_settings/
-    Returns a specific FilterSettings item from the database.
-
-    #### Response format
-    >>> {
-    ...     "id": 1,
-    ...     "ping_type": [
-    ...         "onduty",
-    ...          ...
-    ...     ],
-    ...     "filter_dm": True,
-    ...     "dm_ping_type": [
-    ...         "onduty",
-    ...         ...
-    ...     ],
-    ...     "delete_messages": True,
-    ...     "bypass_roles": [
-    ...         267630620367257601,
-    ...         ...
-    ...     ],
-    ...     "enabled": True,
-    ...     "default_action": 1,
-    ...     "default_range": 1
-    ... }
-
-    #### Status codes
-    - 200: returned on success
-    - 404: returned if the id was not found.
-
-    ### POST /bot/filter/filter_settings
-    Adds a single FilterSettings item to the database.
-
-    #### Request body
-    >>> {
-    ...     "ping_type": [
-    ...         "onduty",
-    ...          ...
-    ...     ],
-    ...     "filter_dm": True,
-    ...     "dm_ping_type": [
-    ...         "onduty",
-    ...         ...
-    ...     ],
-    ...     "delete_messages": True,
-    ...     "bypass_roles": [
-    ...         267630620367257601,
-    ...         ...
-    ...     ],
-    ...     "enabled": True,
-    ...     "default_action": 1,
-    ...     "default_range": 1
-    ... }
-
-    #### Status codes
-    - 201: returned on success
-    - 400: if one of the given fields is invalid
-
-    ### PATCH /bot/filter/filter_settings/
-    Updates a specific FilterSettings item from the database.
-
-    #### Response format
-    >>> {
-    ...     "id": 1,
-    ...     "ping_type": [
-    ...         "onduty",
-    ...          ...
-    ...     ],
-    ...     "filter_dm": True,
-    ...     "dm_ping_type": [
-    ...         "onduty",
-    ...         ...
-    ...     ],
-    ...     "delete_messages": True,
-    ...     "bypass_roles": [
-    ...         267630620367257601,
-    ...         ...
-    ...     ],
-    ...     "enabled": True,
-    ...     "default_action": 1,
-    ...     "default_range": 1
-    ... }
-
-    #### Status codes
-    - 200: returned on success
-    - 400: if one of the given fields is invalid
-
-    ### DELETE /bot/filter/filter_settings/
-    Deletes the FilterSettings item with the given `id`.
-
-    #### Status codes
-    - 204: returned on success
-    - 404: if a tag with the given `id` does not exist
-    """
-
-    serializer_class = FilterSettingsSerializer
-    queryset = FilterSettings.objects.all()
-
-
-class FilterActionViewSet(ModelViewSet):
-    """
-    View providing CRUD operations on actions taken by items allowed or denied by our bot.
-
-    ## Routes
-    ### GET /bot/filter/filter_action
-    Returns all FilterAction items in the database.
-
-    #### Response format
-    >>> [
-    ...     {
-    ...         "id": 1,
-    ...         "dm_content": "message",
-    ...         "infraction_type": "Warn",
-    ...         "infraction_reason": "",
-    ...         "infraction_duration": "01 12:34:56.123456"
-    ...     },
-    ...     ...
-    ... ]
-
-    #### Status codes
-    - 200: returned on success
-    - 401: returned if unauthenticated
-
-    ### GET /bot/filter/filter_action/
-    Returns a specific FilterAction item from the database.
-
-    #### Response format
-    >>> {
-    ...     "id": 1,
-    ...     "dm_content": "message",
-    ...     "infraction_type": "Warn",
-    ...     "infraction_reason": "",
-    ...     "infraction_duration": "01 12:34:56.123456"
-    ... }
-
-    #### Status codes
-    - 200: returned on success
-    - 404: returned if the id was not found.
-
-    ### POST /bot/filter/filter_action
-    Adds a single FilterAction item to the database.
-
-    #### Request body
-    >>> {
-    ...     "dm_content": "message",
-    ...     "infraction_type": "Warn",
-    ...     "infraction_reason": "",
-    ...     "infraction_duration": "01 12:34:56.123456"
-    ... }
-
-    #### Status codes
-    - 201: returned on success
-    - 400: if one of the given fields is invalid
-
-    ### PATCH /bot/filter/filter_action/
-    Updates a specific FilterAction item from the database.
-
-    #### Response format
-    >>> {
-    ...     "id": 1,
-    ...     "dm_content": "message",
-    ...     "infraction_type": "Warn",
-    ...     "infraction_reason": "",
-    ...     "infraction_duration": "01 12:34:56.123456"
-    ... }
-
-    #### Status codes
-    - 200: returned on success
-    - 400: if one of the given fields is invalid
-
-    ### DELETE /bot/filter/filter_action/
-    Deletes the FilterAction item with the given `id`.
-
-    #### Status codes
-    - 204: returned on success
-    - 404: if a tag with the given `id` does not exist
-    """
-
-    serializer_class = FilterActionSerializer
-    queryset = FilterAction.objects.all()
-
-
-class FilterChannelRangeViewSet(ModelViewSet):
-    """
-    View providing CRUD operations on channels targeted by items allowed or denied by our bot.
-
-    ## Routes
-    ### GET /bot/filter/channel_range
-    Returns all ChannelRange items in the database.
-
-    #### Response format
-    >>> [
-    ...     {
-    ...         "id": 1,
-    ...         "disallowed_channels": [],
-    ...         "disallowed_categories": [],
-    ...         "allowed_channels": [],
-    ...         "allowed_categories": [],
-    ...         "default": True
-    ...     },
-    ...     ...
-    ... ]
-
-    #### Status codes
-    - 200: returned on success
-    - 401: returned if unauthenticated
-
-    ### GET /bot/filter/channel_range/
-    Returns a specific ChannelRange item from the database.
-
-    #### Response format
-    >>> {
-    ...     "id": 1,
-    ...     "disallowed_channels": [],
-    ...     "disallowed_categories": [],
-    ...     "allowed_channels": [],
-    ...     "allowed_categories": [],
-    ...     "default": True
-    ... }
-
-    #### Status codes
-    - 200: returned on success
-    - 404: returned if the id was not found.
-
-    ### POST /bot/filter/channel_range
-    Adds a single ChannelRange item to the database.
-
-    #### Request body
-    >>> {
-    ...     "disallowed_channels": [],
-    ...     "disallowed_categories": [],
-    ...     "allowed_channels": [],
-    ...     "allowed_categories": [],
-    ...     "default": True
-    ... }
-
-    #### Status codes
-    - 201: returned on success
-    - 400: if one of the given fields is invalid
-
-    ### PATCH /bot/filter/channel_range/
-    Updates a specific ChannelRange item from the database.
-
-    #### Response format
-    >>> {
-    ...     "id": 1,
-    ...     "disallowed_channels": [],
-    ...     "disallowed_categories": [],
-    ...     "allowed_channels": [],
-    ...     "allowed_categories": [],
-    ...     "default": True
-    ... }
-
-    #### Status codes
-    - 200: returned on success
-    - 400: if one of the given fields is invalid
-
-    ### DELETE /bot/filter/channel_range/
-    Deletes the ChannelRange item with the given `id`.
-
-    #### Status codes
-    - 204: returned on success
-    - 404: if a tag with the given `id` does not exist
-    """
-
-    serializer_class = FilterChannelRangeSerializer
-    queryset = ChannelRange.objects.all()
-
-
 class FilterViewSet(ModelViewSet):
     """
     View providing CRUD operations on items allowed or denied by our bot.
@@ -477,138 +164,3 @@ class FilterViewSet(ModelViewSet):
 
     serializer_class = FilterSerializer
     queryset = Filter.objects.all()
-
-
-class FilterOverrideViewSet(ModelViewSet):
-    """
-    View providing CRUD operations setting overrides of items allowed or denied by our bot.
-
-    ## Routes
-    ### GET /bot/filter/filter_override
-    Returns all FilterOverride items in the database.
-
-    #### Response format
-    >>> [
-    ...     {
-    ...         "id": 1,
-    ...         "ping_type": [
-    ...             "onduty",
-    ...              ...
-    ...          ],
-    ...         "filter_dm": True,
-    ...         "dm_ping_type": [
-    ...             "onduty",
-    ...             ...
-    ...         ],
-    ...         "delete_messages": True,
-    ...         "bypass_roles": [
-    ...             267630620367257601,
-    ...             ...
-    ...         ],
-    ...         "enabled": True,
-    ...         "filter_action": 1,
-    ...         "filter_range": 1
-    ...     },
-    ...     ...
-    ... ]
-
-    #### Status codes
-    - 200: returned on success
-    - 401: returned if unauthenticated
-
-    ### GET /bot/filter/filter_override/
-    Returns a specific FilterOverride item from the database.
-
-    #### Response format
-    >>> {
-    ...     "id": 1,
-    ...     "ping_type": [
-    ...         "onduty",
-    ...          ...
-    ...     ],
-    ...     "filter_dm": True,
-    ...     "dm_ping_type": [
-    ...         "onduty",
-    ...         ...
-    ...     ],
-    ...     "delete_messages": True,
-    ...     "bypass_roles": [
-    ...         267630620367257601,
-    ...         ...
-    ...     ],
-    ...     "enabled": True,
-    ...     "filter_action": 1,
-    ...     "filter_range": 1
-    ... }
-
-    #### Status codes
-    - 200: returned on success
-    - 404: returned if the id was not found.
-
-    ### POST /bot/filter/filter_override
-    Adds a single FilterOverride item to the database.
-
-    #### Request body
-    >>> {
-    ...     "ping_type": [
-    ...         "onduty",
-    ...          ...
-    ...     ],
-    ...     "filter_dm": True,
-    ...     "dm_ping_type": [
-    ...         "onduty",
-    ...         ...
-    ...     ],
-    ...     "delete_messages": True,
-    ...     "bypass_roles": [
-    ...         267630620367257601,
-    ...         ...
-    ...     ],
-    ...     "enabled": True,
-    ...     "filter_action": 1,
-    ...     "filter_range": 1
-    ... }
-
-    #### Status codes
-    - 201: returned on success
-    - 400: if one of the given fields is invalid
-
-    ### PATCH /bot/filter/filter_override/
-    Updates a specific FilterOverride item from the database.
-
-    #### Response format
-    >>> {
-    ...     "id": 1,
-    ...     "ping_type": [
-    ...         "onduty",
-    ...          ...
-    ...     ],
-    ...     "filter_dm": True,
-    ...     "dm_ping_type": [
-    ...         "onduty",
-    ...         ...
-    ...     ],
-    ...     "delete_messages": True,
-    ...     "bypass_roles": [
-    ...         267630620367257601,
-    ...         ...
-    ...     ],
-    ...     "enabled": True,
-    ...     "filter_action": 1,
-    ...     "filter_range": 1
-    ... }
-
-    #### Status codes
-    - 200: returned on success
-    - 400: if one of the given fields is invalid
-
-    ### DELETE /bot/filter/filter_override/
-    Deletes the FilterOverride item with the given `id`.
-
-    #### Status codes
-    - 204: returned on success
-    - 404: if a tag with the given `id` does not exist
-    """
-
-    serializer_class = FilterOverrideSerializer
-    queryset = FilterOverride.objects.all()
-- 
cgit v1.2.3
From faf1948eb39f0389633a6f86f2d4e406f6e83b74 Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Sat, 25 Sep 2021 13:06:20 +0300
Subject: Add AbstractModelMeta mixin
---
 pydis_site/apps/api/models/mixins.py | 5 +++++
 1 file changed, 5 insertions(+)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/models/mixins.py b/pydis_site/apps/api/models/mixins.py
index 5d75b78b..d32e6e72 100644
--- a/pydis_site/apps/api/models/mixins.py
+++ b/pydis_site/apps/api/models/mixins.py
@@ -1,3 +1,4 @@
+from abc import ABCMeta
 from operator import itemgetter
 
 from django.db import models
@@ -29,3 +30,7 @@ class ModelTimestampMixin(models.Model):
         """Metaconfig for the mixin."""
 
         abstract = True
+
+
+class AbstractModelMeta(ABCMeta, type(models.Model)):
+    """Metaclass for ABCModel class."""
-- 
cgit v1.2.3
From 679472436bbb6250fab91d333c3e6fe3a20dea90 Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Sat, 25 Sep 2021 13:06:38 +0300
Subject: Update filters migrations
---
 .../apps/api/migrations/0070_new_filter_schema.py  | 129 +++++++++------------
 .../apps/api/migrations/0071_auto_20210711_0839.py |  44 -------
 2 files changed, 53 insertions(+), 120 deletions(-)
 delete mode 100644 pydis_site/apps/api/migrations/0071_auto_20210711_0839.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index 237ce7d7..7925f5ff 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -14,7 +14,6 @@ OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_N
 def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
     filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
     filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
-    filter_settings: pydis_site.apps.api.models.FilterSettings = apps.get_model("api", "FilterSettings")
     channel_range: pydis_site.apps.api.models.ChannelRange = apps.get_model("api", "ChannelRange")
     filter_action: pydis_site.apps.api.models.FilterAction = apps.get_model("api", "FilterAction")
     filter_list_old = apps.get_model("api", "FilterListOld")
@@ -22,44 +21,47 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
     for name, type_ in OLD_LIST_NAMES:
         objects = filter_list_old.objects.filter(type=name)
 
-        default_action = filter_action.objects.create(
+        list_ = filter_list.objects.create(
+            name=name.lower(),
+            list_type=1 if type_ == "ALLOW" else 0,
+            ping_type=["onduty"],
+            filter_dm=True,
+            dm_ping_type=["onduty"],
+            delete_messages=True,
+            bypass_roles=[267630620367257601],
+            enabled=False,
             dm_content=None,
             infraction_type=None,
             infraction_reason="",
-            infraction_duration=None
-        )
-        default_action.save()
-        default_range = channel_range.objects.create(
+            infraction_duration=None,
             disallowed_channels=[],
             disallowed_categories=[],
             allowed_channels=[],
             allowed_categories=[],
             default=True
         )
-        default_range.save()
-        default_settings = filter_settings.objects.create(
-            ping_type=["onduty"],
-            filter_dm=True,
-            dm_ping_type=["onduty"],
-            delete_messages=True,
-            bypass_roles=[267630620367257601],
-            enabled=False,
-            default_action=default_action,
-            default_range=default_range
-        )
-        default_settings.save()
-        list_ = filter_list.objects.create(
-            name=name.lower(),
-            default_settings=default_settings,
-            list_type=1 if type_ == "ALLOW" else 0
-        )
 
         for object_ in objects:
             new_object = filter_.objects.create(
                 content=object_.content,
-                filter_list = list_,
+                filter_list=list_,
                 description=object_.comment or "",
-                additional_field=None, override=None
+                additional_field=None,
+                ping_type=None,
+                filter_dm=None,
+                dm_ping_type=None,
+                delete_messages=None,
+                bypass_roles=None,
+                enabled=None,
+                dm_content=None,
+                infraction_type=None,
+                infraction_reason="",
+                infraction_duration=None,
+                disallowed_channels=[],
+                disallowed_categories=[],
+                allowed_channels=[],
+                allowed_categories=[],
+                default=False
             )
             new_object.save()
 
@@ -75,17 +77,6 @@ class Migration(migrations.Migration):
             old_name='FilterList',
             new_name='FilterListOld'
         ),
-        migrations.CreateModel(
-            name='ChannelRange',
-            fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('default', models.BooleanField()),
-            ],
-        ),
         migrations.CreateModel(
             name='Filter',
             fields=[
@@ -93,60 +84,46 @@ class Migration(migrations.Migration):
                 ('content', models.CharField(help_text='The definition of this filter.', max_length=100)),
                 ('description', models.CharField(help_text='Why this filter has been added.', max_length=200)),
                 ('additional_field', models.BooleanField(help_text='Implementation specific field.', null=True)),
-            ],
-        ),
-        migrations.CreateModel(
-            name='FilterAction',
-            fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
+                ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)),
+                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
+                ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)),
+                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None, null=True)),
+                ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)),
                 ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
                 ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)),
                 ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)),
                 ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
+                ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('default', models.BooleanField()),
             ],
         ),
         migrations.CreateModel(
-            name='FilterSettings',
+            name='FilterList',
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(help_text='The unique name of this list.', max_length=50)),
+                ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')),
                 ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
-                ('filter_dm', models.BooleanField(help_text='Whenever DMs should be filtered.')),
+                ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')),
                 ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
-                ('delete_messages', models.BooleanField(help_text='Whenever this filter should delete messages triggering it.')),
+                ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')),
                 ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None)),
-                ('enabled', models.BooleanField(help_text='Whenever ths filter is currently enabled.')),
-                ('default_action', models.ForeignKey(help_text='The default action to perform.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')),
-                ('default_range', models.ForeignKey(help_text='Where does this filter apply.', on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')),
-            ],
-        ),
-        migrations.CreateModel(
-            name='FilterOverride',
-            fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), null=True, size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
-                ('filter_dm', models.BooleanField(null=True)),
-                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), null=True, size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
-                ('delete_messages', models.BooleanField(null=True)),
-                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
-                ('enabled', models.BooleanField(null=True)),
-                ('filter_action', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')),
-                ('filter_range', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')),
-            ],
-        ),
-        migrations.CreateModel(
-            name='FilterList',
-            fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('name', models.CharField(help_text='The unique name of this list.', max_length=50)),
-                ('list_type', models.IntegerField(choices=[], help_text='Whenever this list is an allowlist or denylist')),
-                ('default_settings', models.ForeignKey(help_text='Default parameters of this list.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterSettings')),
+                ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')),
+                ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
+                ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)),
+                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)),
+                ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
+                ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('default', models.BooleanField()),
             ],
         ),
-        migrations.AddField(
-            model_name='filter',
-            name='override',
-            field=models.ForeignKey(help_text='Override the default settings.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.FilterOverride'),
-        ),
         migrations.AddField(
             model_name='filter',
             name='filter_list',
diff --git a/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py b/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py
deleted file mode 100644
index e1c45fb6..00000000
--- a/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# Generated by Django 3.0.14 on 2021-07-11 08:39
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('api', '0070_new_filter_schema'),
-    ]
-
-    operations = [
-        migrations.AlterField(
-            model_name='filterlist',
-            name='list_type',
-            field=models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist'),
-        ),
-        migrations.AlterField(
-            model_name='filtersettings',
-            name='default_action',
-            field=models.ForeignKey(help_text='What action to perform on the triggering user.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction'),
-        ),
-        migrations.AlterField(
-            model_name='filtersettings',
-            name='default_range',
-            field=models.ForeignKey(help_text='The channels and categories in which this filter applies.', on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange'),
-        ),
-        migrations.AlterField(
-            model_name='filtersettings',
-            name='delete_messages',
-            field=models.BooleanField(help_text='Whether this filter should delete messages triggering it.'),
-        ),
-        migrations.AlterField(
-            model_name='filtersettings',
-            name='enabled',
-            field=models.BooleanField(help_text='Whether this filter is currently enabled.'),
-        ),
-        migrations.AlterField(
-            model_name='filtersettings',
-            name='filter_dm',
-            field=models.BooleanField(help_text='Whether DMs should be filtered.'),
-        ),
-    ]
-- 
cgit v1.2.3
From 75a4b0eb57520b247ecaa228440b1abbd6c65845 Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Sat, 25 Sep 2021 13:08:01 +0300
Subject: Remove default field from FilterSettingsMixin and migration
---
 pydis_site/apps/api/migrations/0070_new_filter_schema.py | 8 ++------
 pydis_site/apps/api/models/bot/filters.py                | 2 --
 2 files changed, 2 insertions(+), 8 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index 7925f5ff..c1db2a07 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -37,8 +37,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
             disallowed_channels=[],
             disallowed_categories=[],
             allowed_channels=[],
-            allowed_categories=[],
-            default=True
+            allowed_categories=[]
         )
 
         for object_ in objects:
@@ -60,8 +59,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
                 disallowed_channels=[],
                 disallowed_categories=[],
                 allowed_channels=[],
-                allowed_categories=[],
-                default=False
+                allowed_categories=[]
             )
             new_object.save()
 
@@ -98,7 +96,6 @@ class Migration(migrations.Migration):
                 ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
                 ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
                 ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('default', models.BooleanField()),
             ],
         ),
         migrations.CreateModel(
@@ -121,7 +118,6 @@ class Migration(migrations.Migration):
                 ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
                 ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
                 ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('default', models.BooleanField()),
             ],
         ),
         migrations.AddField(
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 68ac191b..365259e7 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -104,12 +104,10 @@ class FilterSettingsMixin(models.Model, metaclass=AbstractModelMeta):
     #   - disallowed categories
     #   - allowed categories
     #   - allowed channels
-    #   - default
     disallowed_channels = ArrayField(models.IntegerField())
     disallowed_categories = ArrayField(models.IntegerField())
     allowed_channels = ArrayField(models.IntegerField())
     allowed_categories = ArrayField(models.IntegerField())
-    default = models.BooleanField()
 
     class Meta:
         """Metaclass for settings mixin."""
-- 
cgit v1.2.3
From c5092f2895447b672dd9101a32997ce8a1c737e3 Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Fri, 8 Oct 2021 19:21:36 +0300
Subject: Remove old models from migration
---
 pydis_site/apps/api/migrations/0070_new_filter_schema.py | 2 --
 1 file changed, 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index c1db2a07..aa114ca1 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -14,8 +14,6 @@ OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_N
 def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
     filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
     filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
-    channel_range: pydis_site.apps.api.models.ChannelRange = apps.get_model("api", "ChannelRange")
-    filter_action: pydis_site.apps.api.models.FilterAction = apps.get_model("api", "FilterAction")
     filter_list_old = apps.get_model("api", "FilterListOld")
 
     for name, type_ in OLD_LIST_NAMES:
-- 
cgit v1.2.3
From 25da18321e82f0a3cd18923d59d86b59acec160d Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Thu, 28 Oct 2021 19:46:07 +0300
Subject: Update filters API to actually work
---
 .../apps/api/migrations/0070_new_filter_schema.py  |  16 +--
 pydis_site/apps/api/models/bot/filters.py          | 129 ++++++++++++---------
 pydis_site/apps/api/models/mixins.py               |   5 -
 pydis_site/apps/api/serializers.py                 |  58 ++++++---
 4 files changed, 123 insertions(+), 85 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index aa114ca1..a595bda2 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -54,10 +54,10 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
                 infraction_type=None,
                 infraction_reason="",
                 infraction_duration=None,
-                disallowed_channels=[],
-                disallowed_categories=[],
-                allowed_channels=[],
-                allowed_categories=[]
+                disallowed_channels=None,
+                disallowed_categories=None,
+                allowed_channels=None,
+                allowed_categories=None
             )
             new_object.save()
 
@@ -90,10 +90,10 @@ class Migration(migrations.Migration):
                 ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)),
                 ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)),
                 ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
-                ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
+                ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
+                ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
+                ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
+                ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
             ],
         ),
         migrations.CreateModel(
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 365259e7..b9a081e6 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -1,4 +1,3 @@
-from abc import abstractmethod
 from typing import List
 
 from django.contrib.postgres.fields import ArrayField
@@ -6,8 +5,6 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.db.models import UniqueConstraint
 
-from pydis_site.apps.api.models.mixins import AbstractModelMeta
-
 
 class FilterListType(models.IntegerChoices):
     """Choice between allow or deny for a list type."""
@@ -43,40 +40,9 @@ def validate_ping_field(value_list: List[str]) -> None:
         raise ValidationError(f"{value!r} isn't a valid ping type.")
 
 
-class FilterSettingsMixin(models.Model, metaclass=AbstractModelMeta):
-    """Mixin for settings of a filter list."""
-
-    @staticmethod
-    @abstractmethod
-    def allow_null() -> bool:
-        """Abstract property for allowing null values."""
+class FilterSettingsMixin(models.Model):
+    """Mixin for common settings of a filters and filter lists."""
 
-    ping_type = ArrayField(
-        models.CharField(max_length=20),
-        validators=(validate_ping_field,),
-        help_text="Who to ping when this filter triggers.",
-        null=allow_null.__func__()
-    )
-    filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True)
-    dm_ping_type = ArrayField(
-        models.CharField(max_length=20),
-        validators=(validate_ping_field,),
-        help_text="Who to ping when this filter triggers on a DM.",
-        null=allow_null.__func__()
-    )
-    delete_messages = models.BooleanField(
-        help_text="Whether this filter should delete messages triggering it.",
-        null=allow_null.__func__()
-    )
-    bypass_roles = ArrayField(
-        models.BigIntegerField(),
-        help_text="Roles and users who can bypass this filter.",
-        null=allow_null.__func__()
-    )
-    enabled = models.BooleanField(
-        help_text="Whether this filter is currently enabled.",
-        null=allow_null.__func__()
-    )
     dm_content = models.CharField(
         max_length=1000,
         null=True,
@@ -97,18 +63,6 @@ class FilterSettingsMixin(models.Model, metaclass=AbstractModelMeta):
         help_text="The duration of the infraction. Null if permanent."
     )
 
-    # Where a filter should apply.
-    #
-    # The resolution is done in the following order:
-    #   - disallowed channels
-    #   - disallowed categories
-    #   - allowed categories
-    #   - allowed channels
-    disallowed_channels = ArrayField(models.IntegerField())
-    disallowed_categories = ArrayField(models.IntegerField())
-    allowed_channels = ArrayField(models.IntegerField())
-    allowed_categories = ArrayField(models.IntegerField())
-
     class Meta:
         """Metaclass for settings mixin."""
 
@@ -123,11 +77,43 @@ class FilterList(FilterSettingsMixin):
         choices=FilterListType.choices,
         help_text="Whether this list is an allowlist or denylist"
     )
-
-    @staticmethod
-    def allow_null() -> bool:
-        """Do not allow null values for default settings."""
-        return False
+    ping_type = ArrayField(
+        models.CharField(max_length=20),
+        validators=(validate_ping_field,),
+        help_text="Who to ping when this filter triggers.",
+        null=False
+    )
+    filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False)
+    dm_ping_type = ArrayField(
+        models.CharField(max_length=20),
+        validators=(validate_ping_field,),
+        help_text="Who to ping when this filter triggers on a DM.",
+        null=False
+    )
+    delete_messages = models.BooleanField(
+        help_text="Whether this filter should delete messages triggering it.",
+        null=False
+    )
+    bypass_roles = ArrayField(
+        models.BigIntegerField(),
+        help_text="Roles and users who can bypass this filter.",
+        null=False
+    )
+    enabled = models.BooleanField(
+        help_text="Whether this filter is currently enabled.",
+        null=False
+    )
+    # Where a filter should apply.
+    #
+    # The resolution is done in the following order:
+    #   - disallowed channels
+    #   - disallowed categories
+    #   - allowed categories
+    #   - allowed channels
+    disallowed_channels = ArrayField(models.IntegerField())
+    disallowed_categories = ArrayField(models.IntegerField())
+    allowed_channels = ArrayField(models.IntegerField())
+    allowed_categories = ArrayField(models.IntegerField())
 
     class Meta:
         """Constrain name and list_type unique."""
@@ -150,11 +136,38 @@ class Filter(FilterSettingsMixin):
         FilterList, models.CASCADE, related_name="filters",
         help_text="The filter list containing this filter."
     )
+    ping_type = ArrayField(
+        models.CharField(max_length=20),
+        validators=(validate_ping_field,),
+        help_text="Who to ping when this filter triggers.",
+        null=True
+    )
+    filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True)
+    dm_ping_type = ArrayField(
+        models.CharField(max_length=20),
+        validators=(validate_ping_field,),
+        help_text="Who to ping when this filter triggers on a DM.",
+        null=True
+    )
+    delete_messages = models.BooleanField(
+        help_text="Whether this filter should delete messages triggering it.",
+        null=True
+    )
+    bypass_roles = ArrayField(
+        models.BigIntegerField(),
+        help_text="Roles and users who can bypass this filter.",
+        null=True
+    )
+    enabled = models.BooleanField(
+        help_text="Whether this filter is currently enabled.",
+        null=True
+    )
+
+    # Check FilterList model for information about these properties.
+    disallowed_channels = ArrayField(models.IntegerField(), null=True)
+    disallowed_categories = ArrayField(models.IntegerField(), null=True)
+    allowed_channels = ArrayField(models.IntegerField(), null=True)
+    allowed_categories = ArrayField(models.IntegerField(), null=True)
 
     def __str__(self) -> str:
         return f"Filter {self.content!r}"
-
-    @staticmethod
-    def allow_null() -> bool:
-        """Allow null values for overrides."""
-        return True
diff --git a/pydis_site/apps/api/models/mixins.py b/pydis_site/apps/api/models/mixins.py
index d32e6e72..5d75b78b 100644
--- a/pydis_site/apps/api/models/mixins.py
+++ b/pydis_site/apps/api/models/mixins.py
@@ -1,4 +1,3 @@
-from abc import ABCMeta
 from operator import itemgetter
 
 from django.db import models
@@ -30,7 +29,3 @@ class ModelTimestampMixin(models.Model):
         """Metaconfig for the mixin."""
 
         abstract = True
-
-
-class AbstractModelMeta(ABCMeta, type(models.Model)):
-    """Metaclass for ABCModel class."""
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index ff2bd929..4e92b3a0 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -113,6 +113,29 @@ class DocumentationLinkSerializer(ModelSerializer):
         fields = ('package', 'base_url', 'inventory_url')
 
 
+ALWAYS_OPTIONAL_SETTINGS = (
+    'dm_content',
+    'infraction_type',
+    'infraction_reason',
+    'infraction_duration',
+)
+
+REQUIRED_FOR_FILTER_LIST_SETTINGS = (
+    'ping_type',
+    'filter_dm',
+    'dm_ping_type',
+    'delete_messages',
+    'bypass_roles',
+    'enabled',
+    'disallowed_channels',
+    'disallowed_categories',
+    'allowed_channels',
+    'allowed_categories',
+)
+
+SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS
+
+
 class FilterSerializer(ModelSerializer):
     """A class providing (de-)serialization of `Filter` instances."""
 
@@ -120,7 +143,16 @@ class FilterSerializer(ModelSerializer):
         """Metadata defined for the Django REST Framework."""
 
         model = Filter
-        fields = ('id', 'content', 'description', 'additional_field', 'filter_list', 'override')
+        fields = ('id', 'content', 'description', 'additional_field', 'filter_list') + SETTINGS_FIELDS
+        extra_kwargs = {
+            field: {'required': False, 'allow_null': True} for field in SETTINGS_FIELDS
+        } | {
+            'infraction_reason': {'allow_blank': True, 'allow_null': True, 'required': False},
+            'disallowed_channels': {'allow_empty': True, 'allow_null': True, 'required': False},
+            'disallowed_categories': {'allow_empty': True, 'allow_null': True, 'required': False},
+            'allowed_channels': {'allow_empty': True, 'allow_null': True, 'required': False},
+            'allowed_categories': {'allow_empty': True, 'allow_null': True, 'required': False},
+        }
 
 
 class FilterListSerializer(ModelSerializer):
@@ -132,18 +164,16 @@ class FilterListSerializer(ModelSerializer):
         """Metadata defined for the Django REST Framework."""
 
         model = FilterList
-        fields = (
-            'id',
-            'name',
-            'list_type',
-            'filters',
-            'ping_type',
-            'filter_dm',
-            'dm_ping_type',
-            'delete_messages',
-            'bypass_roles',
-            ''
-        )
+        fields = ('id', 'name', 'list_type', 'filters') + SETTINGS_FIELDS
+        extra_kwargs = {
+            field: {'required': False, 'allow_null': True} for field in ALWAYS_OPTIONAL_SETTINGS
+        } | {
+            'infraction_reason': {'allow_blank': True, 'allow_null': True, 'required': False},
+            'disallowed_channels': {'allow_empty': True},
+            'disallowed_categories': {'allow_empty': True},
+            'allowed_channels': {'allow_empty': True},
+            'allowed_categories': {'allow_empty': True},
+        }
 
         # Ensure that we can only have one filter list with the same name and field
         validators = [
@@ -200,7 +230,7 @@ class InfractionSerializer(ModelSerializer):
         if hidden and infr_type in ('superstar', 'warning', 'voice_ban'):
             raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']})
 
-        if not hidden and infr_type in ('note', ):
+        if not hidden and infr_type in ('note',):
             raise ValidationError({'hidden': [f'{infr_type} infractions must be hidden.']})
 
         return attrs
-- 
cgit v1.2.3
From 4c2eaff72ba9e95e1ef8d7b40396187783d87a50 Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Thu, 28 Oct 2021 20:15:36 +0300
Subject: Add basic validation for infraction fields + use common infraction
 types
---
 pydis_site/apps/api/models/bot/filters.py | 19 ++++++++-----------
 1 file changed, 8 insertions(+), 11 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index b9a081e6..eebcf703 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.db.models import UniqueConstraint
 
+from pydis_site.apps.api.models import Infraction
+
 
 class FilterListType(models.IntegerChoices):
     """Choice between allow or deny for a list type."""
@@ -13,16 +15,6 @@ class FilterListType(models.IntegerChoices):
     DENY = 0
 
 
-class InfractionType(models.TextChoices):
-    """Possible type of infractions."""
-
-    NOTE = "Note"
-    WARN = "Warn"
-    MUTE = "Mute"
-    KICK = "Kick"
-    BAN = "Ban"
-
-
 # Valid special values in ping related fields
 VALID_PINGS = ("everyone", "here", "moderators", "onduty", "admins")
 
@@ -49,7 +41,7 @@ class FilterSettingsMixin(models.Model):
         help_text="The DM to send to a user triggering this filter."
     )
     infraction_type = models.CharField(
-        choices=InfractionType.choices,
+        choices=Infraction.TYPE_CHOICES,
         max_length=4,
         null=True,
         help_text="The infraction to apply to this user."
@@ -63,6 +55,11 @@ class FilterSettingsMixin(models.Model):
         help_text="The duration of the infraction. Null if permanent."
     )
 
+    def clean(self):
+        """Validate infraction fields as whole."""
+        if (self.infraction_duration or self.infraction_reason) and not self.infraction_type:
+            raise ValidationError("Infraction type is required if setting infraction duration or reason.")
+
     class Meta:
         """Metaclass for settings mixin."""
 
-- 
cgit v1.2.3
From d8ad1bdbcfcc8a0881c0ceb4d7d486455d23e170 Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Thu, 28 Oct 2021 20:31:09 +0300
Subject: Add validation to filters to not allow duplicated channels and
 categories
---
 pydis_site/apps/api/models/bot/filters.py | 29 +++++++++++++++++++++++++++++
 1 file changed, 29 insertions(+)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index eebcf703..45dea2c4 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -112,6 +112,20 @@ class FilterList(FilterSettingsMixin):
     allowed_channels = ArrayField(models.IntegerField())
     allowed_categories = ArrayField(models.IntegerField())
 
+    def clean(self):
+        """Do not allow duplicates in allowed and disallowed lists."""
+        # Still run infraction fields validation
+        super().clean()
+
+        channels_collection = self.allowed_channels + self.disallowed_channels
+        categories_collection = self.allowed_categories + self.disallowed_categories
+
+        if len(channels_collection) != len(set(channels_collection)):
+            raise ValidationError("Allowed and disallowed channels lists contain duplicates.")
+
+        if len(categories_collection) != len(set(categories_collection)):
+            raise ValidationError("Allowed and disallowed categories lists contain duplicates.")
+
     class Meta:
         """Constrain name and list_type unique."""
 
@@ -166,5 +180,20 @@ class Filter(FilterSettingsMixin):
     allowed_channels = ArrayField(models.IntegerField(), null=True)
     allowed_categories = ArrayField(models.IntegerField(), null=True)
 
+    def clean(self):
+        """Do not allow duplicates in allowed and disallowed lists."""
+        # Still run infraction fields validation
+        super().clean()
+
+        if self.allowed_channels is not None or self.disallowed_channels is not None:
+            channels_collection = self.allowed_channels + self.disallowed_channels
+            if len(channels_collection) != len(set(channels_collection)):
+                raise ValidationError("Allowed and disallowed channels lists contain duplicates.")
+
+        if self.allowed_categories is not None or self.disallowed_categories is not None:
+            categories_collection = self.allowed_categories + self.disallowed_categories
+            if len(categories_collection) != len(set(categories_collection)):
+                raise ValidationError("Allowed and disallowed categories lists contain duplicates.")
+
     def __str__(self) -> str:
         return f"Filter {self.content!r}"
-- 
cgit v1.2.3
From f4152448dfa4cd9912c22134af01fe37f0b153f6 Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Thu, 28 Oct 2021 20:38:59 +0300
Subject: Add validation to filters to not allow duplicates + additional_field
 -> JSON
---
 pydis_site/apps/api/migrations/0070_new_filter_schema.py | 6 +++---
 pydis_site/apps/api/models/bot/filters.py                | 9 +++++----
 2 files changed, 8 insertions(+), 7 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index a595bda2..8716cbad 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -79,7 +79,7 @@ class Migration(migrations.Migration):
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('content', models.CharField(help_text='The definition of this filter.', max_length=100)),
                 ('description', models.CharField(help_text='Why this filter has been added.', max_length=200)),
-                ('additional_field', models.BooleanField(help_text='Implementation specific field.', null=True)),
+                ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)),
                 ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
                 ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)),
                 ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
@@ -87,7 +87,7 @@ class Migration(migrations.Migration):
                 ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None, null=True)),
                 ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)),
                 ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
-                ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)),
+                ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)),
                 ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)),
                 ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
                 ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
@@ -109,7 +109,7 @@ class Migration(migrations.Migration):
                 ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None)),
                 ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')),
                 ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
-                ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)),
+                ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)),
                 ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)),
                 ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
                 ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 45dea2c4..472354f8 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -1,11 +1,12 @@
 from typing import List
 
-from django.contrib.postgres.fields import ArrayField
+from django.contrib.postgres.fields import ArrayField, JSONField
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db.models import UniqueConstraint
 
-from pydis_site.apps.api.models import Infraction
+# Must be imported that way to avoid circular imports
+from .infraction import Infraction
 
 
 class FilterListType(models.IntegerChoices):
@@ -42,7 +43,7 @@ class FilterSettingsMixin(models.Model):
     )
     infraction_type = models.CharField(
         choices=Infraction.TYPE_CHOICES,
-        max_length=4,
+        max_length=9,
         null=True,
         help_text="The infraction to apply to this user."
     )
@@ -142,7 +143,7 @@ class Filter(FilterSettingsMixin):
 
     content = models.CharField(max_length=100, help_text="The definition of this filter.")
     description = models.CharField(max_length=200, help_text="Why this filter has been added.")
-    additional_field = models.BooleanField(null=True, help_text="Implementation specific field.")
+    additional_field = JSONField(null=True, help_text="Implementation specific field.")
     filter_list = models.ForeignKey(
         FilterList, models.CASCADE, related_name="filters",
         help_text="The filter list containing this filter."
-- 
cgit v1.2.3
From b49612db59ca075f64a4c7da11e3c9ce7e7b19eb Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Fri, 29 Oct 2021 20:11:27 +0300
Subject: Move filters validations to serializers
---
 pydis_site/apps/api/models/bot/filters.py | 34 -------------------------------
 pydis_site/apps/api/serializers.py        | 33 ++++++++++++++++++++++++++++++
 2 files changed, 33 insertions(+), 34 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 472354f8..3a1f3c6a 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -56,11 +56,6 @@ class FilterSettingsMixin(models.Model):
         help_text="The duration of the infraction. Null if permanent."
     )
 
-    def clean(self):
-        """Validate infraction fields as whole."""
-        if (self.infraction_duration or self.infraction_reason) and not self.infraction_type:
-            raise ValidationError("Infraction type is required if setting infraction duration or reason.")
-
     class Meta:
         """Metaclass for settings mixin."""
 
@@ -113,20 +108,6 @@ class FilterList(FilterSettingsMixin):
     allowed_channels = ArrayField(models.IntegerField())
     allowed_categories = ArrayField(models.IntegerField())
 
-    def clean(self):
-        """Do not allow duplicates in allowed and disallowed lists."""
-        # Still run infraction fields validation
-        super().clean()
-
-        channels_collection = self.allowed_channels + self.disallowed_channels
-        categories_collection = self.allowed_categories + self.disallowed_categories
-
-        if len(channels_collection) != len(set(channels_collection)):
-            raise ValidationError("Allowed and disallowed channels lists contain duplicates.")
-
-        if len(categories_collection) != len(set(categories_collection)):
-            raise ValidationError("Allowed and disallowed categories lists contain duplicates.")
-
     class Meta:
         """Constrain name and list_type unique."""
 
@@ -181,20 +162,5 @@ class Filter(FilterSettingsMixin):
     allowed_channels = ArrayField(models.IntegerField(), null=True)
     allowed_categories = ArrayField(models.IntegerField(), null=True)
 
-    def clean(self):
-        """Do not allow duplicates in allowed and disallowed lists."""
-        # Still run infraction fields validation
-        super().clean()
-
-        if self.allowed_channels is not None or self.disallowed_channels is not None:
-            channels_collection = self.allowed_channels + self.disallowed_channels
-            if len(channels_collection) != len(set(channels_collection)):
-                raise ValidationError("Allowed and disallowed channels lists contain duplicates.")
-
-        if self.allowed_categories is not None or self.disallowed_categories is not None:
-            categories_collection = self.allowed_categories + self.disallowed_categories
-            if len(categories_collection) != len(set(categories_collection)):
-                raise ValidationError("Allowed and disallowed categories lists contain duplicates.")
-
     def __str__(self) -> str:
         return f"Filter {self.content!r}"
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 4e92b3a0..b5f083b0 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -139,6 +139,23 @@ SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS
 class FilterSerializer(ModelSerializer):
     """A class providing (de-)serialization of `Filter` instances."""
 
+    def validate(self, data):
+        """Perform infraction data + allow and disallowed lists validation."""
+        if (data.get('infraction_reason') or data.get('infraction_duration')) and not data.get('infraction_type'):
+            raise ValidationError("Infraction type is required with infraction duration or reason")
+
+        if data.get('allowed_channels') is not None and data.get('disallowed_channels') is not None:
+            channels_collection = data['allowed_channels'] + data['disallowed_channels']
+            if len(channels_collection) != len(set(channels_collection)):
+                raise ValidationError("Allowed and disallowed channels lists contain duplicates.")
+
+        if data.get('allowed_categories') is not None and data.get('disallowed_categories') is not None:
+            categories_collection = data['allowed_categories'] + data['disallowed_categories']
+            if len(categories_collection) != len(set(categories_collection)):
+                raise ValidationError("Allowed and disallowed categories lists contain duplicates.")
+
+        return data
+
     class Meta:
         """Metadata defined for the Django REST Framework."""
 
@@ -160,6 +177,22 @@ class FilterListSerializer(ModelSerializer):
 
     filters = FilterSerializer(many=True, read_only=True)
 
+    def validate(self, data):
+        """Perform infraction data + allow and disallowed lists validation."""
+        if (data['infraction_reason'] or data['infraction_duration']) and not data['infraction_type']:
+            raise ValidationError("Infraction type is required with infraction duration or reason")
+
+        channels_collection = data['allowed_channels'] + data['disallowed_channels']
+        categories_collection = data['allowed_categories'] + data['disallowed_categories']
+
+        if len(channels_collection) != len(set(channels_collection)):
+            raise ValidationError("Allowed and disallowed channels lists contain duplicates.")
+
+        if len(categories_collection) != len(set(categories_collection)):
+            raise ValidationError("Allowed and disallowed categories lists contain duplicates.")
+
+        return data
+
     class Meta:
         """Metadata defined for the Django REST Framework."""
 
-- 
cgit v1.2.3
From 55d9288f11e2981eb9251f92164a597869c07cf9 Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Fri, 29 Oct 2021 20:11:38 +0300
Subject: Add merge migration
---
 pydis_site/apps/api/migrations/0074_merge_20211017_0822.py | 14 ++++++++++++++
 1 file changed, 14 insertions(+)
 create mode 100644 pydis_site/apps/api/migrations/0074_merge_20211017_0822.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py b/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py
new file mode 100644
index 00000000..ae41ac71
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.14 on 2021-10-17 08:22
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0073_otn_allow_GT_and_LT'),
+        ('api', '0070_new_filter_schema'),
+    ]
+
+    operations = [
+    ]
-- 
cgit v1.2.3
From 7d21797b8bc14b92a48bc782694e226b2562c1b5 Mon Sep 17 00:00:00 2001
From: ks129 <45097959+ks129@users.noreply.github.com>
Date: Fri, 29 Oct 2021 20:17:03 +0300
Subject: Fix linting
---
 pydis_site/apps/api/serializers.py | 26 +++++++++++++++++++-------
 1 file changed, 19 insertions(+), 7 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index b5f083b0..c82b0797 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -139,17 +139,25 @@ SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS
 class FilterSerializer(ModelSerializer):
     """A class providing (de-)serialization of `Filter` instances."""
 
-    def validate(self, data):
+    def validate(self, data: dict) -> dict:
         """Perform infraction data + allow and disallowed lists validation."""
-        if (data.get('infraction_reason') or data.get('infraction_duration')) and not data.get('infraction_type'):
+        if (
+            data.get('infraction_reason') or data.get('infraction_duration')
+        ) and not data.get('infraction_type'):
             raise ValidationError("Infraction type is required with infraction duration or reason")
 
-        if data.get('allowed_channels') is not None and data.get('disallowed_channels') is not None:
+        if (
+            data.get('allowed_channels') is not None
+            and data.get('disallowed_channels') is not None
+        ):
             channels_collection = data['allowed_channels'] + data['disallowed_channels']
             if len(channels_collection) != len(set(channels_collection)):
                 raise ValidationError("Allowed and disallowed channels lists contain duplicates.")
 
-        if data.get('allowed_categories') is not None and data.get('disallowed_categories') is not None:
+        if (
+            data.get('allowed_categories') is not None
+            and data.get('disallowed_categories') is not None
+        ):
             categories_collection = data['allowed_categories'] + data['disallowed_categories']
             if len(categories_collection) != len(set(categories_collection)):
                 raise ValidationError("Allowed and disallowed categories lists contain duplicates.")
@@ -160,7 +168,9 @@ class FilterSerializer(ModelSerializer):
         """Metadata defined for the Django REST Framework."""
 
         model = Filter
-        fields = ('id', 'content', 'description', 'additional_field', 'filter_list') + SETTINGS_FIELDS
+        fields = (
+            'id', 'content', 'description', 'additional_field', 'filter_list'
+        ) + SETTINGS_FIELDS
         extra_kwargs = {
             field: {'required': False, 'allow_null': True} for field in SETTINGS_FIELDS
         } | {
@@ -177,9 +187,11 @@ class FilterListSerializer(ModelSerializer):
 
     filters = FilterSerializer(many=True, read_only=True)
 
-    def validate(self, data):
+    def validate(self, data: dict) -> dict:
         """Perform infraction data + allow and disallowed lists validation."""
-        if (data['infraction_reason'] or data['infraction_duration']) and not data['infraction_type']:
+        if (
+            data['infraction_reason'] or data['infraction_duration']
+        ) and not data['infraction_type']:
             raise ValidationError("Infraction type is required with infraction duration or reason")
 
         channels_collection = data['allowed_channels'] + data['disallowed_channels']
-- 
cgit v1.2.3
From 8ab32d7820b57b9f3edb61d4bd93864b6037502b Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Sun, 5 Dec 2021 16:09:56 +0100
Subject: Adjust Filter JSON Schema
From now on the Serializer will have a different JSON representation than the
table schema itself, conforming to the format needed on the bot-side.
---
 pydis_site/apps/api/serializers.py          |  30 +++++++
 pydis_site/apps/api/viewsets/bot/filters.py | 127 +++++++++++++++++++++-------
 2 files changed, 127 insertions(+), 30 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index c82b0797..864ab52e 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -133,6 +133,17 @@ REQUIRED_FOR_FILTER_LIST_SETTINGS = (
     'allowed_categories',
 )
 
+# Required fields for custom JSON representation purposes
+BASE_FIELDS = ('id', 'content', 'description', 'additional_field')
+BASE_SETTINGS_FIELDS = ("ping_type", "dm_ping_type", "bypass_roles", "filter_dm")
+INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration")
+CHANNEL_SCOPE_FIELDS = (
+    "allowed_channels",
+    "allowed_categories",
+    "disallowed_channels",
+    "disallowed_categories"
+)
+
 SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS
 
 
@@ -181,6 +192,25 @@ class FilterSerializer(ModelSerializer):
             'allowed_categories': {'allow_empty': True, 'allow_null': True, 'required': False},
         }
 
+    def to_representation(self, instance: Filter) -> dict:
+        """
+        Provides a custom JSON representation to the Filter Serializers
+
+        That does not affect how the Serializer works in general.
+        """
+        item = Filter.objects.get(id=instance.id)
+        schema_settings = {
+            "settings":
+                {name: getattr(item, name) for name in BASE_SETTINGS_FIELDS}
+                | {"infraction": {name: getattr(item, name) for name in INFRACTION_FIELDS}}
+                | {"channel_scope": {name: getattr(item, name) for name in CHANNEL_SCOPE_FIELDS}}
+        }
+
+        schema_base = {name: getattr(item, name) for name in BASE_FIELDS} | \
+                      {"filter_list": item.filter_list.id}
+
+        return schema_base | schema_settings
+
 
 class FilterListSerializer(ModelSerializer):
     """A class providing (de-)serialization of `FilterList` instances."""
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
index 5b21de26..64329ebe 100644
--- a/pydis_site/apps/api/viewsets/bot/filters.py
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -27,11 +27,28 @@ class FilterListViewSet(ModelViewSet):
     ...         "filters": [
     ...             {
     ...                 "id": 1,
+    ...                 "filter_list": 1
     ...                 "content": "267624335836053506",
     ...                 "description": "Python Discord",
     ...                 "additional_field": None,
-    ...                 "override": 1,
-    ...                 "filter_list": 1
+    ...                 "settings": {
+    ...                        "ping_type": None,
+    ...                        "dm_ping_type": None
+    ...                        "bypass_roles": None
+    ...                        "filter_dm": None,
+    ...                        "infraction": {
+    ...                            "infraction_type": None,
+    ...                            "infraction_reason": "",
+    ...                            "infraction_duration": None
+    ...                        },
+    ...                        "channel_scope": {
+    ...                            "allowed_channels": None,
+    ...                            "allowed_categories": None,
+    ...                            "disallowed_channels": None,
+    ...                            "disallowed_categories": None
+    ...                        }
+    ...                    }
+    ...
     ...             },
     ...             ...
     ...         ],
@@ -48,23 +65,40 @@ class FilterListViewSet(ModelViewSet):
     Returns a specific FilterList item from the database.
 
     #### Response format
-    >>> {
-    ...     "id": 1,
-    ...     "name": "guild_invite",
-    ...     "list_type": 1,
-    ...     "filters": [
-    ...         {
-    ...             "id": 1,
-    ...             "content": "267624335836053506",
-    ...             "description": "Python Discord",
-    ...             "additional_field": None,
-    ...             "override": 1,
-    ...             "filter_list": 1
-    ...         },
-    ...         ...
-    ...     ],
-    ...     "default_settings": 1
-    ... }
+    ...     {
+    ...         "id": 1,
+    ...         "name": "guild_invite",
+    ...         "list_type": 1,
+    ...         "filters": [
+    ...             {
+    ...                 "id": 1,
+    ...                 "filter_list": 1
+    ...                 "content": "267624335836053506",
+    ...                 "description": "Python Discord",
+    ...                 "additional_field": None,
+    ...                 "settings": {
+    ...                        "ping_type": None,
+    ...                        "dm_ping_type": None
+    ...                        "bypass_roles": None
+    ...                        "filter_dm": None,
+    ...                        "infraction": {
+    ...                            "infraction_type": None,
+    ...                            "infraction_reason": "",
+    ...                            "infraction_duration": None
+    ...                        },
+    ...                        "channel_scope": {
+    ...                            "allowed_channels": None,
+    ...                            "allowed_categories": None,
+    ...                            "disallowed_channels": None,
+    ...                            "disallowed_categories": None
+    ...                        }
+    ...                    }
+    ...
+    ...             },
+    ...             ...
+    ...         ],
+    ...         "default_settings": 1
+    ...     }
 
     #### Status codes
     - 200: returned on success
@@ -93,12 +127,28 @@ class FilterViewSet(ModelViewSet):
     #### Response format
     >>> [
     ...     {
-    ...         "id": 1,
-    ...         "content": "267624335836053506",
-    ...         "description": "Python Discord",
-    ...         "additional_field": None,
-    ...         "override": 1,
-    ...         "filter_list": 1
+    ...                 "id": 1,
+    ...                 "filter_list": 1
+    ...                 "content": "267624335836053506",
+    ...                 "description": "Python Discord",
+    ...                 "additional_field": None,
+    ...                 "settings": {
+    ...                        "ping_type": None,
+    ...                        "dm_ping_type": None
+    ...                        "bypass_roles": None
+    ...                        "filter_dm": None,
+    ...                        "infraction": {
+    ...                            "infraction_type": None,
+    ...                            "infraction_reason": "",
+    ...                            "infraction_duration": None
+    ...                        },
+    ...                        "channel_scope": {
+    ...                            "allowed_channels": None,
+    ...                            "allowed_categories": None,
+    ...                            "disallowed_channels": None,
+    ...                            "disallowed_categories": None
+    ...                        }
+    ...                    }
     ...     },
     ...     ...
     ... ]
@@ -112,11 +162,28 @@ class FilterViewSet(ModelViewSet):
 
     #### Response format
     >>> {
-    ...     "id": 1,
-    ...     "content": "267624335836053506",
-    ...     "description": "Python Discord",
-    ...     "additional_field": None,
-    ...     "override": 1
+    ...                 "id": 1,
+    ...                 "filter_list": 1
+    ...                 "content": "267624335836053506",
+    ...                 "description": "Python Discord",
+    ...                 "additional_field": None,
+    ...                 "settings": {
+    ...                        "ping_type": None,
+    ...                        "dm_ping_type": None
+    ...                        "bypass_roles": None
+    ...                        "filter_dm": None,
+    ...                        "infraction": {
+    ...                            "infraction_type": None,
+    ...                            "infraction_reason": "",
+    ...                            "infraction_duration": None
+    ...                        },
+    ...                        "channel_scope": {
+    ...                            "allowed_channels": None,
+    ...                            "allowed_categories": None,
+    ...                            "disallowed_channels": None,
+    ...                            "disallowed_categories": None
+    ...                        }
+    ...                    }
     ... }
 
     #### Status codes
-- 
cgit v1.2.3
From e3a45e09041898ffd0bccd3c730524e8c673e696 Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Mon, 6 Dec 2021 20:22:01 +0100
Subject: Adjust FilterList Representation
From now on the FilterList Serializer will contain a settings field with all the settings that were listed previously, on the model.
---
 pydis_site/apps/api/serializers.py          | 17 ++++++++--
 pydis_site/apps/api/viewsets/bot/filters.py | 49 +++++++++++++++++++++++++++--
 2 files changed, 60 insertions(+), 6 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 864ab52e..267cf761 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -181,7 +181,7 @@ class FilterSerializer(ModelSerializer):
         model = Filter
         fields = (
             'id', 'content', 'description', 'additional_field', 'filter_list'
-        ) + SETTINGS_FIELDS
+        )
         extra_kwargs = {
             field: {'required': False, 'allow_null': True} for field in SETTINGS_FIELDS
         } | {
@@ -194,7 +194,8 @@ class FilterSerializer(ModelSerializer):
 
     def to_representation(self, instance: Filter) -> dict:
         """
-        Provides a custom JSON representation to the Filter Serializers
+
+        Provides a custom JSON representation to the Filter Serializers.
 
         That does not affect how the Serializer works in general.
         """
@@ -239,7 +240,7 @@ class FilterListSerializer(ModelSerializer):
         """Metadata defined for the Django REST Framework."""
 
         model = FilterList
-        fields = ('id', 'name', 'list_type', 'filters') + SETTINGS_FIELDS
+        fields = ('id', 'name', 'list_type', 'filters')
         extra_kwargs = {
             field: {'required': False, 'allow_null': True} for field in ALWAYS_OPTIONAL_SETTINGS
         } | {
@@ -261,6 +262,16 @@ class FilterListSerializer(ModelSerializer):
             ),
         ]
 
+    def to_representation(self, instance: FilterList) -> dict:
+        """
+        Provides a custom JSON representation to the FilterList Serializers.
+
+        That does not affect how the Serializer works in general.
+        """
+        ret = super().to_representation(instance)
+        ret["settings"] = {name: getattr(instance, name) for name in SETTINGS_FIELDS}
+        return ret
+
 
 class InfractionSerializer(ModelSerializer):
     """A class providing (de-)serialization of `Infraction` instances."""
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
index 64329ebe..cbadcf2b 100644
--- a/pydis_site/apps/api/viewsets/bot/filters.py
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -52,7 +52,28 @@ class FilterListViewSet(ModelViewSet):
     ...             },
     ...             ...
     ...         ],
-    ...         "default_settings": 1
+    ...     "settings": {
+    ...            "dm_content": None,
+    ...            "infraction_type": None,
+    ...            "infraction_reason": "",
+    ...            "infraction_duration": None,
+    ...            "ping_type": [
+    ...                "onduty"
+    ...            ],
+    ...            "filter_dm": True,
+    ...            "dm_ping_type": [
+    ...                "onduty"
+    ...            ],
+    ...            "delete_messages": True,
+    ...            "bypass_roles": [
+    ...                267630620367257601
+    ...            ],
+    ...            "enabled": False,
+    ...            "disallowed_channels": [],
+    ...            "disallowed_categories": [],
+    ...            "allowed_channels": [],
+    ...            "allowed_categories": []
+    ...    }
     ...     },
     ...     ...
     ... ]
@@ -65,6 +86,7 @@ class FilterListViewSet(ModelViewSet):
     Returns a specific FilterList item from the database.
 
     #### Response format
+    >>>
     ...     {
     ...         "id": 1,
     ...         "name": "guild_invite",
@@ -95,9 +117,30 @@ class FilterListViewSet(ModelViewSet):
     ...                    }
     ...
     ...             },
-    ...             ...
+    ...
     ...         ],
-    ...         "default_settings": 1
+    ...            "settings": {
+    ...            "dm_content": None,
+    ...            "infraction_type": None,
+    ...            "infraction_reason": "",
+    ...            "infraction_duration": None,
+    ...            "ping_type": [
+    ...                "onduty"
+    ...            ],
+    ...            "filter_dm": True,
+    ...            "dm_ping_type": [
+    ...                "onduty"
+    ...            ],
+    ...            "delete_messages": True,
+    ...            "bypass_roles": [
+    ...                267630620367257601
+    ...            ],
+    ...            "enabled": False,
+    ...            "disallowed_channels": [],
+    ...            "disallowed_categories": [],
+    ...            "allowed_channels": [],
+    ...            "allowed_categories": []
+    ...         }
     ...     }
 
     #### Status codes
-- 
cgit v1.2.3
From 3e8f164525bdd3a728bb7383da237feb9aacb44e Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Thu, 9 Dec 2021 20:53:49 +0100
Subject: Adjust FilterList Schema to group settings into subcategories
- This commit patches the FilterList serializer's schema, and puts the settings into the relevant subcategories.
---
 pydis_site/apps/api/serializers.py          |  9 ++-
 pydis_site/apps/api/viewsets/bot/filters.py | 93 ++++++++++++++---------------
 2 files changed, 54 insertions(+), 48 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 267cf761..89005a9b 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -269,7 +269,14 @@ class FilterListSerializer(ModelSerializer):
         That does not affect how the Serializer works in general.
         """
         ret = super().to_representation(instance)
-        ret["settings"] = {name: getattr(instance, name) for name in SETTINGS_FIELDS}
+        schema_base = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
+        schema_settings = {
+            "infraction":
+            {name: getattr(instance, name) for name in INFRACTION_FIELDS}} \
+            | {
+            "channel_scope":
+            {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}}
+        ret["settings"] = schema_base | schema_settings
         return ret
 
 
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
index cbadcf2b..2b587696 100644
--- a/pydis_site/apps/api/viewsets/bot/filters.py
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -52,31 +52,29 @@ class FilterListViewSet(ModelViewSet):
     ...             },
     ...             ...
     ...         ],
-    ...     "settings": {
-    ...            "dm_content": None,
-    ...            "infraction_type": None,
-    ...            "infraction_reason": "",
-    ...            "infraction_duration": None,
-    ...            "ping_type": [
-    ...                "onduty"
-    ...            ],
-    ...            "filter_dm": True,
-    ...            "dm_ping_type": [
-    ...                "onduty"
-    ...            ],
-    ...            "delete_messages": True,
-    ...            "bypass_roles": [
-    ...                267630620367257601
-    ...            ],
-    ...            "enabled": False,
-    ...            "disallowed_channels": [],
-    ...            "disallowed_categories": [],
-    ...            "allowed_channels": [],
-    ...            "allowed_categories": []
-    ...    }
-    ...     },
-    ...     ...
-    ... ]
+    ...            "settings": {
+    ...              "ping_type": [
+    ...                  "onduty"
+    ...              ],
+    ...              "dm_ping_type": [
+    ...                  "onduty"
+    ...              ],
+    ...              "bypass_roles": [
+    ...                  267630620367257601
+    ...              ],
+    ...              "filter_dm": True,
+    ...              "infraction": {
+    ...                   "infraction_type": None,
+    ...                   "infraction_reason": "",
+    ...                   "infraction_duration": None,
+    ...               }
+    ...              "channel_scope": {
+    ...                "disallowed_channels": [],
+    ...                "disallowed_categories": [],
+    ...                "allowed_channels": [],
+    ...                "allowed_categories": []
+    ...               }
+    ...           }
 
     #### Status codes
     - 200: returned on success
@@ -120,28 +118,29 @@ class FilterListViewSet(ModelViewSet):
     ...
     ...         ],
     ...            "settings": {
-    ...            "dm_content": None,
-    ...            "infraction_type": None,
-    ...            "infraction_reason": "",
-    ...            "infraction_duration": None,
-    ...            "ping_type": [
-    ...                "onduty"
-    ...            ],
-    ...            "filter_dm": True,
-    ...            "dm_ping_type": [
-    ...                "onduty"
-    ...            ],
-    ...            "delete_messages": True,
-    ...            "bypass_roles": [
-    ...                267630620367257601
-    ...            ],
-    ...            "enabled": False,
-    ...            "disallowed_channels": [],
-    ...            "disallowed_categories": [],
-    ...            "allowed_channels": [],
-    ...            "allowed_categories": []
-    ...         }
-    ...     }
+    ...              "ping_type": [
+    ...                  "onduty"
+    ...              ],
+    ...              "dm_ping_type": [
+    ...                  "onduty"
+    ...              ],
+    ...              "bypass_roles": [
+    ...                  267630620367257601
+    ...              ],
+    ...              "filter_dm": True,
+    ...              "infraction": {
+    ...                   "infraction_type": None,
+    ...                   "infraction_reason": "",
+    ...                   "infraction_duration": None,
+    ...               }
+    ...              "channel_scope": {
+    ...                "disallowed_channels": [],
+    ...                "disallowed_categories": [],
+    ...                "allowed_channels": [],
+    ...                "allowed_categories": []
+    ...               }
+    ...           }
+    ...       }
 
     #### Status codes
     - 200: returned on success
-- 
cgit v1.2.3
From a24cac8d43893f792d4fa495cf2a9ce65f69051c Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Fri, 10 Dec 2021 22:19:09 +0100
Subject: Patch Filter and FilterList Serializer validation logic and
 representation
- This commit patches an error with the FilterListSerializer validation logic, so that it won't raise an error when an optional field is not present.
- It also adds the `enabled` and `delete_messages` fields, to the FilterSerializer's representation
- Furthermore the commit introduces minor bug patches, regarding DRF Serializer Fields.
---
 pydis_site/apps/api/serializers.py          | 73 +++++++++++++++++++----------
 pydis_site/apps/api/viewsets/bot/filters.py | 12 +++++
 2 files changed, 61 insertions(+), 24 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 89005a9b..cb8313ac 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -134,8 +134,16 @@ REQUIRED_FOR_FILTER_LIST_SETTINGS = (
 )
 
 # Required fields for custom JSON representation purposes
-BASE_FIELDS = ('id', 'content', 'description', 'additional_field')
-BASE_SETTINGS_FIELDS = ("ping_type", "dm_ping_type", "bypass_roles", "filter_dm")
+BASE_FILTER_FIELDS = ('id', 'content', 'description', 'additional_field')
+BASE_FILTERLIST_FIELDS = ('id', 'name', 'list_type')
+BASE_SETTINGS_FIELDS = (
+    "ping_type",
+    "dm_ping_type",
+    "bypass_roles",
+    "filter_dm",
+    "enabled",
+    "delete_messages"
+)
 INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration")
 CHANNEL_SCOPE_FIELDS = (
     "allowed_channels",
@@ -181,7 +189,7 @@ class FilterSerializer(ModelSerializer):
         model = Filter
         fields = (
             'id', 'content', 'description', 'additional_field', 'filter_list'
-        )
+        ) + SETTINGS_FIELDS
         extra_kwargs = {
             field: {'required': False, 'allow_null': True} for field in SETTINGS_FIELDS
         } | {
@@ -199,16 +207,18 @@ class FilterSerializer(ModelSerializer):
 
         That does not affect how the Serializer works in general.
         """
-        item = Filter.objects.get(id=instance.id)
         schema_settings = {
             "settings":
-                {name: getattr(item, name) for name in BASE_SETTINGS_FIELDS}
-                | {"infraction": {name: getattr(item, name) for name in INFRACTION_FIELDS}}
-                | {"channel_scope": {name: getattr(item, name) for name in CHANNEL_SCOPE_FIELDS}}
+                {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
+                | {"infraction": {name: getattr(instance, name) for name in INFRACTION_FIELDS}}
+                | {
+                    "channel_scope":
+                        {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}
+                }
         }
 
-        schema_base = {name: getattr(item, name) for name in BASE_FIELDS} | \
-                      {"filter_list": item.filter_list.id}
+        schema_base = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS} | \
+                      {"filter_list": instance.filter_list.id}
 
         return schema_base | schema_settings
 
@@ -221,18 +231,25 @@ class FilterListSerializer(ModelSerializer):
     def validate(self, data: dict) -> dict:
         """Perform infraction data + allow and disallowed lists validation."""
         if (
-            data['infraction_reason'] or data['infraction_duration']
-        ) and not data['infraction_type']:
+            data.get('infraction_reason') or data.get('infraction_duration')
+        ) and not data.get('infraction_type'):
             raise ValidationError("Infraction type is required with infraction duration or reason")
 
-        channels_collection = data['allowed_channels'] + data['disallowed_channels']
-        categories_collection = data['allowed_categories'] + data['disallowed_categories']
-
-        if len(channels_collection) != len(set(channels_collection)):
-            raise ValidationError("Allowed and disallowed channels lists contain duplicates.")
+        if (
+            data.get('allowed_channels') is not None
+            and data.get('disallowed_channels') is not None
+        ):
+            channels_collection = data['allowed_channels'] + data['disallowed_channels']
+            if len(channels_collection) != len(set(channels_collection)):
+                raise ValidationError("Allowed and disallowed channels lists contain duplicates.")
 
-        if len(categories_collection) != len(set(categories_collection)):
-            raise ValidationError("Allowed and disallowed categories lists contain duplicates.")
+        if (
+            data.get('allowed_categories') is not None
+            and data.get('disallowed_categories') is not None
+        ):
+            categories_collection = data['allowed_categories'] + data['disallowed_categories']
+            if len(categories_collection) != len(set(categories_collection)):
+                raise ValidationError("Allowed and disallowed categories lists contain duplicates.")
 
         return data
 
@@ -240,7 +257,7 @@ class FilterListSerializer(ModelSerializer):
         """Metadata defined for the Django REST Framework."""
 
         model = FilterList
-        fields = ('id', 'name', 'list_type', 'filters')
+        fields = ('id', 'name', 'list_type', 'filters') + SETTINGS_FIELDS
         extra_kwargs = {
             field: {'required': False, 'allow_null': True} for field in ALWAYS_OPTIONAL_SETTINGS
         } | {
@@ -268,16 +285,24 @@ class FilterListSerializer(ModelSerializer):
 
         That does not affect how the Serializer works in general.
         """
-        ret = super().to_representation(instance)
-        schema_base = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
-        schema_settings = {
+        # Fetches the relating filters
+        filters = [
+            FilterSerializer(many=False).to_representation(
+                instance=item
+            ) for item in Filter.objects.filter(
+                filter_list=instance.id
+            )
+        ]
+        schema_base = {name: getattr(instance, name) for name in BASE_FILTERLIST_FIELDS} \
+            | {"filters": filters}
+        schema_settings_base = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
+        schema_settings_categories = {
             "infraction":
             {name: getattr(instance, name) for name in INFRACTION_FIELDS}} \
             | {
             "channel_scope":
             {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}}
-        ret["settings"] = schema_base | schema_settings
-        return ret
+        return schema_base | {"settings": schema_settings_base | schema_settings_categories}
 
 
 class InfractionSerializer(ModelSerializer):
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
index 2b587696..e8f3e3d9 100644
--- a/pydis_site/apps/api/viewsets/bot/filters.py
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -36,6 +36,8 @@ class FilterListViewSet(ModelViewSet):
     ...                        "dm_ping_type": None
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
+    ...                        "enabled": False
+    ...                        "delete_messages": True
     ...                        "infraction": {
     ...                            "infraction_type": None,
     ...                            "infraction_reason": "",
@@ -63,6 +65,8 @@ class FilterListViewSet(ModelViewSet):
     ...                  267630620367257601
     ...              ],
     ...              "filter_dm": True,
+    ...              "enabled": False
+    ...              "delete_messages": True
     ...              "infraction": {
     ...                   "infraction_type": None,
     ...                   "infraction_reason": "",
@@ -101,6 +105,8 @@ class FilterListViewSet(ModelViewSet):
     ...                        "dm_ping_type": None
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
+    ...                        "enabled": False
+    ...                        "delete_messages": True
     ...                        "infraction": {
     ...                            "infraction_type": None,
     ...                            "infraction_reason": "",
@@ -128,6 +134,8 @@ class FilterListViewSet(ModelViewSet):
     ...                  267630620367257601
     ...              ],
     ...              "filter_dm": True,
+    ...              "enabled": False
+    ...              "delete_messages": True
     ...              "infraction": {
     ...                   "infraction_type": None,
     ...                   "infraction_reason": "",
@@ -179,6 +187,8 @@ class FilterViewSet(ModelViewSet):
     ...                        "dm_ping_type": None
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
+    ...                        "enabled": False
+    ...                        "delete_messages": True
     ...                        "infraction": {
     ...                            "infraction_type": None,
     ...                            "infraction_reason": "",
@@ -214,6 +224,8 @@ class FilterViewSet(ModelViewSet):
     ...                        "dm_ping_type": None
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
+    ...                        "enabled": False
+    ...                        "delete_messages": True
     ...                        "infraction": {
     ...                            "infraction_type": None,
     ...                            "infraction_reason": "",
-- 
cgit v1.2.3
From 4c93b1b9b75cce4e45bdbdae608f4497372c2b56 Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Wed, 15 Dec 2021 19:25:55 +0100
Subject: Prepare FilterList and Filter models, serializers for the new filter
 schema
- Rename channel scope fields:
 - "allowed" -> "disabled"
eg.: "allowed_channels" -> "disabled_channels"
- Rename FilterLists` names:
filter_token -> tokens
domain_name -> domains
guild_invite -> invites
file_format -> formats
- Patch the docs and validators accordingly.
---
 ..._filter_and_filterlist_for_new_filter_schema.py | 80 ++++++++++++++++++
 pydis_site/apps/api/models/bot/filters.py          | 21 +++--
 pydis_site/apps/api/serializers.py                 | 90 +++++++++++----------
 pydis_site/apps/api/viewsets/bot/filters.py        | 94 ++++++++++++----------
 4 files changed, 189 insertions(+), 96 deletions(-)
 create mode 100644 pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
new file mode 100644
index 00000000..30537e3d
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
@@ -0,0 +1,80 @@
+# Generated by Django 3.0.14 on 2021-12-11 23:14
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+    FilterList = apps.get_model("api", "FilterList")
+    change_map = {
+        "filter_token": "tokens",
+        "domain_name": "domains",
+        "guild_invite": "invites",
+        "file_format": "formats"
+    }
+    for filter_list in FilterList.objects.all():
+        if change_map.get(filter_list.name):
+            filter_list.name = change_map.get(filter_list.name)
+            filter_list.save()
+
+
+def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+    FilterList = apps.get_model("api", "FilterList")
+    change_map = {
+        "tokens": "filter_token",
+        "domains": "domain_name",
+        "invites": "guild_invite",
+        "formats": "file_format"
+    }
+    for filter_list in FilterList.objects.all():
+        if change_map.get(filter_list.name):
+            filter_list.name = change_map.get(filter_list.name)
+            filter_list.save()
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('api', '0074_merge_20211017_0822'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='filter',
+            old_name='allowed_categories',
+            new_name='disabled_categories',
+        ),
+        migrations.RenameField(
+            model_name='filter',
+            old_name='allowed_channels',
+            new_name='disabled_channels',
+        ),
+        migrations.RenameField(
+            model_name='filter',
+            old_name='disallowed_channels',
+            new_name='enabled_channels',
+        ),
+        migrations.RenameField(
+            model_name='filterlist',
+            old_name='allowed_categories',
+            new_name='disabled_categories',
+        ),
+        migrations.RenameField(
+            model_name='filterlist',
+            old_name='allowed_channels',
+            new_name='disabled_channels',
+        ),
+        migrations.RenameField(
+            model_name='filterlist',
+            old_name='disallowed_channels',
+            new_name='enabled_channels',
+        ),
+        migrations.RemoveField(
+            model_name='filterlist',
+            name='disallowed_categories',
+        ),
+        migrations.RemoveField(
+            model_name='filter',
+            name='disallowed_categories',
+        ),
+        migrations.RunPython(migrate_filterlist, unmigrate_filterlist)
+    ]
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 3a1f3c6a..ae877685 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -99,14 +99,12 @@ class FilterList(FilterSettingsMixin):
     # Where a filter should apply.
     #
     # The resolution is done in the following order:
-    #   - disallowed channels
-    #   - disallowed categories
-    #   - allowed categories
-    #   - allowed channels
-    disallowed_channels = ArrayField(models.IntegerField())
-    disallowed_categories = ArrayField(models.IntegerField())
-    allowed_channels = ArrayField(models.IntegerField())
-    allowed_categories = ArrayField(models.IntegerField())
+    #   - enabled_channels
+    #   - disabled_categories
+    #   - disabled_channels
+    enabled_channels = ArrayField(models.IntegerField())
+    disabled_channels = ArrayField(models.IntegerField())
+    disabled_categories = ArrayField(models.IntegerField())
 
     class Meta:
         """Constrain name and list_type unique."""
@@ -157,10 +155,9 @@ class Filter(FilterSettingsMixin):
     )
 
     # Check FilterList model for information about these properties.
-    disallowed_channels = ArrayField(models.IntegerField(), null=True)
-    disallowed_categories = ArrayField(models.IntegerField(), null=True)
-    allowed_channels = ArrayField(models.IntegerField(), null=True)
-    allowed_categories = ArrayField(models.IntegerField(), null=True)
+    enabled_channels = ArrayField(models.IntegerField(), null=True)
+    disabled_channels = ArrayField(models.IntegerField(), null=True)
+    disabled_categories = ArrayField(models.IntegerField(), null=True)
 
     def __str__(self) -> str:
         return f"Filter {self.content!r}"
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index cb8313ac..784f8160 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -127,18 +127,15 @@ REQUIRED_FOR_FILTER_LIST_SETTINGS = (
     'delete_messages',
     'bypass_roles',
     'enabled',
-    'disallowed_channels',
-    'disallowed_categories',
-    'allowed_channels',
-    'allowed_categories',
+    'enabled_channels',
+    'disabled_channels',
+    'disabled_categories',
 )
 
 # Required fields for custom JSON representation purposes
 BASE_FILTER_FIELDS = ('id', 'content', 'description', 'additional_field')
 BASE_FILTERLIST_FIELDS = ('id', 'name', 'list_type')
 BASE_SETTINGS_FIELDS = (
-    "ping_type",
-    "dm_ping_type",
     "bypass_roles",
     "filter_dm",
     "enabled",
@@ -146,11 +143,11 @@ BASE_SETTINGS_FIELDS = (
 )
 INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration")
 CHANNEL_SCOPE_FIELDS = (
-    "allowed_channels",
-    "allowed_categories",
-    "disallowed_channels",
-    "disallowed_categories"
+    "disabled_channels",
+    "disabled_categories",
+    "enabled_channels",
 )
+MENTIONS_FIELDS = ("ping_type", "dm_ping_type")
 
 SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS
 
@@ -166,20 +163,17 @@ class FilterSerializer(ModelSerializer):
             raise ValidationError("Infraction type is required with infraction duration or reason")
 
         if (
-            data.get('allowed_channels') is not None
-            and data.get('disallowed_channels') is not None
+            data.get('disabled_channels') is not None
+            and data.get('enabled_channels') is not None
         ):
-            channels_collection = data['allowed_channels'] + data['disallowed_channels']
+            channels_collection = data['disabled_channels'] + data['enabled_channels']
             if len(channels_collection) != len(set(channels_collection)):
-                raise ValidationError("Allowed and disallowed channels lists contain duplicates.")
+                raise ValidationError("Enabled and Disabled channels lists contain duplicates.")
 
-        if (
-            data.get('allowed_categories') is not None
-            and data.get('disallowed_categories') is not None
-        ):
-            categories_collection = data['allowed_categories'] + data['disallowed_categories']
+        if data.get('disabled_categories') is not None:
+            categories_collection = data['disabled_categories']
             if len(categories_collection) != len(set(categories_collection)):
-                raise ValidationError("Allowed and disallowed categories lists contain duplicates.")
+                raise ValidationError("Disabled categories lists contain duplicates.")
 
         return data
 
@@ -194,18 +188,20 @@ class FilterSerializer(ModelSerializer):
             field: {'required': False, 'allow_null': True} for field in SETTINGS_FIELDS
         } | {
             'infraction_reason': {'allow_blank': True, 'allow_null': True, 'required': False},
-            'disallowed_channels': {'allow_empty': True, 'allow_null': True, 'required': False},
-            'disallowed_categories': {'allow_empty': True, 'allow_null': True, 'required': False},
-            'allowed_channels': {'allow_empty': True, 'allow_null': True, 'required': False},
-            'allowed_categories': {'allow_empty': True, 'allow_null': True, 'required': False},
+            'enabled_channels': {'allow_empty': True, 'allow_null': True, 'required': False},
+            'disabled_channels': {'allow_empty': True, 'allow_null': True, 'required': False},
+            'disabled_categories': {'allow_empty': True, 'allow_null': True, 'required': False},
         }
 
     def to_representation(self, instance: Filter) -> dict:
         """
-
         Provides a custom JSON representation to the Filter Serializers.
 
-        That does not affect how the Serializer works in general.
+        This representation restructures how the Filter is represented.
+        It groups the Infraction, Channel and Mention related fields into their own separated group.
+
+        Furthermore, it puts the fields that meant to represent Filter settings,
+        into a sub-field called `settings`.
         """
         schema_settings = {
             "settings":
@@ -214,6 +210,11 @@ class FilterSerializer(ModelSerializer):
                 | {
                     "channel_scope":
                         {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}
+                } | {
+                    "mentions":
+                        {
+                            schema_field_name: getattr(instance, schema_field_name)
+                            for schema_field_name in MENTIONS_FIELDS}
                 }
         }
 
@@ -236,20 +237,17 @@ class FilterListSerializer(ModelSerializer):
             raise ValidationError("Infraction type is required with infraction duration or reason")
 
         if (
-            data.get('allowed_channels') is not None
-            and data.get('disallowed_channels') is not None
+            data.get('disabled_channels') is not None
+            and data.get('enabled_channels') is not None
         ):
-            channels_collection = data['allowed_channels'] + data['disallowed_channels']
+            channels_collection = data['disabled_channels'] + data['enabled_channels']
             if len(channels_collection) != len(set(channels_collection)):
-                raise ValidationError("Allowed and disallowed channels lists contain duplicates.")
+                raise ValidationError("Enabled and Disabled channels lists contain duplicates.")
 
-        if (
-            data.get('allowed_categories') is not None
-            and data.get('disallowed_categories') is not None
-        ):
-            categories_collection = data['allowed_categories'] + data['disallowed_categories']
+        if data.get('disabled_categories') is not None:
+            categories_collection = data['disabled_categories']
             if len(categories_collection) != len(set(categories_collection)):
-                raise ValidationError("Allowed and disallowed categories lists contain duplicates.")
+                raise ValidationError("Disabled categories lists contain duplicates.")
 
         return data
 
@@ -262,10 +260,9 @@ class FilterListSerializer(ModelSerializer):
             field: {'required': False, 'allow_null': True} for field in ALWAYS_OPTIONAL_SETTINGS
         } | {
             'infraction_reason': {'allow_blank': True, 'allow_null': True, 'required': False},
-            'disallowed_channels': {'allow_empty': True},
-            'disallowed_categories': {'allow_empty': True},
-            'allowed_channels': {'allow_empty': True},
-            'allowed_categories': {'allow_empty': True},
+            'enabled_channels': {'allow_empty': True},
+            'disabled_channels': {'allow_empty': True},
+            'disabled_categories': {'allow_empty': True},
         }
 
         # Ensure that we can only have one filter list with the same name and field
@@ -283,7 +280,11 @@ class FilterListSerializer(ModelSerializer):
         """
         Provides a custom JSON representation to the FilterList Serializers.
 
-        That does not affect how the Serializer works in general.
+        This representation restructures how the Filter is represented.
+        It groups the Infraction, Channel and Mention related fields into their own separated group.
+
+        Furthermore, it puts the fields that meant to represent FilterList settings,
+        into a sub-field called `settings`.
         """
         # Fetches the relating filters
         filters = [
@@ -301,7 +302,12 @@ class FilterListSerializer(ModelSerializer):
             {name: getattr(instance, name) for name in INFRACTION_FIELDS}} \
             | {
             "channel_scope":
-            {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}}
+            {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}} | {
+            "mentions": {
+                schema_field_name: getattr(instance, schema_field_name)
+                for schema_field_name in MENTIONS_FIELDS
+            }
+        }
         return schema_base | {"settings": schema_settings_base | schema_settings_categories}
 
 
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
index e8f3e3d9..20af079d 100644
--- a/pydis_site/apps/api/viewsets/bot/filters.py
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -32,8 +32,6 @@ class FilterListViewSet(ModelViewSet):
     ...                 "description": "Python Discord",
     ...                 "additional_field": None,
     ...                 "settings": {
-    ...                        "ping_type": None,
-    ...                        "dm_ping_type": None
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
     ...                        "enabled": False
@@ -44,11 +42,14 @@ class FilterListViewSet(ModelViewSet):
     ...                            "infraction_duration": None
     ...                        },
     ...                        "channel_scope": {
-    ...                            "allowed_channels": None,
-    ...                            "allowed_categories": None,
-    ...                            "disallowed_channels": None,
-    ...                            "disallowed_categories": None
+    ...                            "disabled_channels": None,
+    ...                            "disabled_categories": None,
+    ...                            "enabled_channels": None
     ...                        }
+    ...                        "mentions": {
+    ...                            "ping_type": None
+    ...                            "dm_ping_type": None
+    ...                         }
     ...                    }
     ...
     ...             },
@@ -72,13 +73,18 @@ class FilterListViewSet(ModelViewSet):
     ...                   "infraction_reason": "",
     ...                   "infraction_duration": None,
     ...               }
-    ...              "channel_scope": {
-    ...                "disallowed_channels": [],
-    ...                "disallowed_categories": [],
-    ...                "allowed_channels": [],
-    ...                "allowed_categories": []
-    ...               }
-    ...           }
+    ...               "channel_scope": {
+    ...                 "disabled_channels": None,
+    ...                 "disabled_categories": None,
+    ...                 "enabled_channels": None
+    ...                }
+    ...               "mentions": {
+    ...                 "ping_type": None
+    ...                 "dm_ping_type": None
+    ...                }
+    ...           },
+    ...     ...
+    ... ]
 
     #### Status codes
     - 200: returned on success
@@ -88,8 +94,7 @@ class FilterListViewSet(ModelViewSet):
     Returns a specific FilterList item from the database.
 
     #### Response format
-    >>>
-    ...     {
+    >>> {
     ...         "id": 1,
     ...         "name": "guild_invite",
     ...         "list_type": 1,
@@ -101,8 +106,6 @@ class FilterListViewSet(ModelViewSet):
     ...                 "description": "Python Discord",
     ...                 "additional_field": None,
     ...                 "settings": {
-    ...                        "ping_type": None,
-    ...                        "dm_ping_type": None
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
     ...                        "enabled": False
@@ -113,15 +116,18 @@ class FilterListViewSet(ModelViewSet):
     ...                            "infraction_duration": None
     ...                        },
     ...                        "channel_scope": {
-    ...                            "allowed_channels": None,
-    ...                            "allowed_categories": None,
-    ...                            "disallowed_channels": None,
-    ...                            "disallowed_categories": None
+    ...                            "disabled_channels": None,
+    ...                            "disabled_categories": None,
+    ...                            "enabled_channels": None
     ...                        }
+    ...                        "mentions": {
+    ...                            "ping_type": None
+    ...                            "dm_ping_type": None
+    ...                         }
     ...                    }
     ...
     ...             },
-    ...
+    ...             ...
     ...         ],
     ...            "settings": {
     ...              "ping_type": [
@@ -141,14 +147,16 @@ class FilterListViewSet(ModelViewSet):
     ...                   "infraction_reason": "",
     ...                   "infraction_duration": None,
     ...               }
-    ...              "channel_scope": {
-    ...                "disallowed_channels": [],
-    ...                "disallowed_categories": [],
-    ...                "allowed_channels": [],
-    ...                "allowed_categories": []
-    ...               }
-    ...           }
-    ...       }
+    ...               "channel_scope": {
+    ...                 "disabled_channels": None,
+    ...                 "disabled_categories": None,
+    ...                 "enabled_channels": None
+    ...                }
+    ...               "mentions": {
+    ...                 "ping_type": None
+    ...                 "dm_ping_type": None
+    ...                }
+    ... }
 
     #### Status codes
     - 200: returned on success
@@ -183,8 +191,6 @@ class FilterViewSet(ModelViewSet):
     ...                 "description": "Python Discord",
     ...                 "additional_field": None,
     ...                 "settings": {
-    ...                        "ping_type": None,
-    ...                        "dm_ping_type": None
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
     ...                        "enabled": False
@@ -195,11 +201,14 @@ class FilterViewSet(ModelViewSet):
     ...                            "infraction_duration": None
     ...                        },
     ...                        "channel_scope": {
-    ...                            "allowed_channels": None,
-    ...                            "allowed_categories": None,
-    ...                            "disallowed_channels": None,
-    ...                            "disallowed_categories": None
+    ...                          "disabled_channels": None,
+    ...                          "disabled_categories": None,
+    ...                          "enabled_channels": None
     ...                        }
+    ...                        "mentions": {
+    ...                          "ping_type": None,
+    ...                          "dm_ping_type": None
+    ...                       }
     ...                    }
     ...     },
     ...     ...
@@ -220,8 +229,6 @@ class FilterViewSet(ModelViewSet):
     ...                 "description": "Python Discord",
     ...                 "additional_field": None,
     ...                 "settings": {
-    ...                        "ping_type": None,
-    ...                        "dm_ping_type": None
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
     ...                        "enabled": False
@@ -232,11 +239,14 @@ class FilterViewSet(ModelViewSet):
     ...                            "infraction_duration": None
     ...                        },
     ...                        "channel_scope": {
-    ...                            "allowed_channels": None,
-    ...                            "allowed_categories": None,
-    ...                            "disallowed_channels": None,
-    ...                            "disallowed_categories": None
+    ...                          "disabled_channels": None,
+    ...                          "disabled_categories": None,
+    ...                          "enabled_channels": None,
     ...                        }
+    ...                       "mentions": {
+    ...                         "ping_type": None
+    ...                         "dm_ping_type": None
+    ...                       }
     ...                    }
     ... }
 
-- 
cgit v1.2.3
From af3980fe65b997287ceaf68e53ce3ab7bf4607e5 Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Wed, 22 Dec 2021 18:18:22 +0100
Subject: Patch Filter/FilterList's default values and add new fields
- Patch default values, so that further implementations can be performed on the bot side
- Add three new fields: "send_alert", and in settings under the "server_message" field: "send_message_text", and "server_message_embed" fields.
- Patch documentation, and validators accordingly.
- Perform further patches, and minor corrections.
---
 .../apps/api/migrations/0070_new_filter_schema.py  |  28 ++++--
 ..._filter_and_filterlist_for_new_filter_schema.py |  17 +++-
 .../api/migrations/0078_merge_20211218_2200.py     |  14 +++
 .../0079_add_server_message_and_alert_fields.py    |  69 ++++++++++++++
 pydis_site/apps/api/models/bot/filters.py          |  44 ++++++++-
 pydis_site/apps/api/serializers.py                 |  19 +++-
 pydis_site/apps/api/viewsets/bot/filters.py        | 104 ++++++++++++---------
 7 files changed, 234 insertions(+), 61 deletions(-)
 create mode 100644 pydis_site/apps/api/migrations/0078_merge_20211218_2200.py
 create mode 100644 pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index 8716cbad..f56c29f8 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -1,4 +1,5 @@
 # Modified migration file to migrate existing filters to the new one
+from datetime import timedelta
 
 import django.contrib.postgres.fields
 from django.apps.registry import Apps
@@ -18,20 +19,27 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
 
     for name, type_ in OLD_LIST_NAMES:
         objects = filter_list_old.objects.filter(type=name)
+        if name == "DOMAIN_NAME":
+            dm_content = "Your URL has been removed because it matched a blacklisted domain: {match}"
+        elif name == "GUILD_INVITE":
+            dm_content = "Per Rule 6, your invite link has been removed. " \
+                         "Our server rules can be found here: https://pythondiscord.com/pages/rules"
+        else:
+            dm_content = ""
 
         list_ = filter_list.objects.create(
             name=name.lower(),
             list_type=1 if type_ == "ALLOW" else 0,
-            ping_type=["onduty"],
+            ping_type=(["onduty"] if name != "FILE_FORMAT" else []),
             filter_dm=True,
-            dm_ping_type=["onduty"],
-            delete_messages=True,
-            bypass_roles=[267630620367257601],
-            enabled=False,
-            dm_content=None,
-            infraction_type=None,
+            dm_ping_type=[],
+            delete_messages=(True if name != "FILTER_TOKEN" else False),
+            bypass_roles=["staff"],
+            enabled=True,
+            dm_content=dm_content,
+            infraction_type="",
             infraction_reason="",
-            infraction_duration=None,
+            infraction_duration=timedelta(seconds=0),
             disallowed_channels=[],
             disallowed_categories=[],
             allowed_channels=[],
@@ -84,7 +92,7 @@ class Migration(migrations.Migration):
                 ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)),
                 ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
                 ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)),
-                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None, null=True)),
+                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field], null=True)),
                 ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)),
                 ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
                 ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)),
@@ -106,7 +114,7 @@ class Migration(migrations.Migration):
                 ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')),
                 ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
                 ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')),
-                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None)),
+                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field])),
                 ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')),
                 ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
                 ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)),
diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
index 30537e3d..cc524fcb 100644
--- a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
@@ -10,12 +10,26 @@ def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> N
         "filter_token": "tokens",
         "domain_name": "domains",
         "guild_invite": "invites",
-        "file_format": "formats"
+        "file_format": "extensions"
     }
     for filter_list in FilterList.objects.all():
         if change_map.get(filter_list.name):
             filter_list.name = change_map.get(filter_list.name)
             filter_list.save()
+    redirects = FilterList(
+        name="redirects",
+        ping_type=[],
+        dm_ping_type=[],
+        enabled_channels=[],
+        disabled_channels=[],
+        disabled_categories=[],
+        list_type=0,
+        filter_dm=True,
+        delete_messages=False,
+        bypass_roles=[0],
+        enabled=True
+    )
+    redirects.save()
 
 
 def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
@@ -30,6 +44,7 @@ def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) ->
         if change_map.get(filter_list.name):
             filter_list.name = change_map.get(filter_list.name)
             filter_list.save()
+    FilterList.objects.filter(name="redirects").delete()
 
 
 class Migration(migrations.Migration):
diff --git a/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py b/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py
new file mode 100644
index 00000000..7fe559f5
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.1.14 on 2021-12-18 22:00
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0075_prepare_filter_and_filterlist_for_new_filter_schema'),
+        ('api', '0077_use_generic_jsonfield'),
+    ]
+
+    operations = [
+    ]
diff --git a/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py
new file mode 100644
index 00000000..f9803bd3
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py
@@ -0,0 +1,69 @@
+# Generated by Django 3.1.14 on 2021-12-19 23:05
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+    FilterList = apps.get_model("api", "FilterList")
+    change_map = {
+        "tokens": True,
+        "domains": True,
+        "invites": True,
+        "extensions": False,
+        "redirects": False
+    }
+    for filter_list in FilterList.objects.all():
+        filter_list.send_alert = change_map.get(filter_list.name)
+        filter_list.server_message_text = ""
+        filter_list.server_message_embed = ""
+        filter_list.save()
+
+
+def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+    FilterList = apps.get_model("api", "FilterList")
+    for filter_list in FilterList.objects.all():
+        filter_list.send_alert = True
+        filter_list.server_message_text = None
+        filter_list.server_message_embed = None
+        filter_list.save()
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('api', '0078_merge_20211218_2200'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='filter',
+            name='send_alert',
+            field=models.BooleanField(help_text='Whether alert should be sent.', null=True),
+        ),
+        migrations.AddField(
+            model_name='filter',
+            name='server_message_embed',
+            field=models.CharField(help_text='The content of the server message embed', max_length=100, null=True),
+        ),
+        migrations.AddField(
+            model_name='filter',
+            name='server_message_text',
+            field=models.CharField(help_text='The message to send on the server', max_length=100, null=True),
+        ),
+        migrations.AddField(
+            model_name='filterlist',
+            name='send_alert',
+            field=models.BooleanField(default=True, help_text='Whether alert should be sent.'),
+        ),
+        migrations.AddField(
+            model_name='filterlist',
+            name='server_message_embed',
+            field=models.CharField(help_text='The content of the server message embed', max_length=100, null=True),
+        ),
+        migrations.AddField(
+            model_name='filterlist',
+            name='server_message_text',
+            field=models.CharField(help_text='The message to send on the server', max_length=100, null=True),
+        ),
+        migrations.RunPython(migrate_filterlist, unmigrate_filterlist)
+    ]
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index ae877685..92251ee4 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -18,6 +18,7 @@ class FilterListType(models.IntegerChoices):
 
 # Valid special values in ping related fields
 VALID_PINGS = ("everyone", "here", "moderators", "onduty", "admins")
+VALID_BYPASS_ROLES = ("staff",)
 
 
 def validate_ping_field(value_list: List[str]) -> None:
@@ -33,6 +34,14 @@ def validate_ping_field(value_list: List[str]) -> None:
         raise ValidationError(f"{value!r} isn't a valid ping type.")
 
 
+def validate_bypass_roles_field(value_list: List[str]) -> None:
+    """Validate that the vclues are either a special value or a Role ID."""
+    for value in value_list:
+        if value.isnumeric() or value in VALID_BYPASS_ROLES:
+            continue
+        raise ValidationError(f"{value!r} isn't a valid (bypass) role.")
+
+
 class FilterSettingsMixin(models.Model):
     """Mixin for common settings of a filters and filter lists."""
 
@@ -88,14 +97,30 @@ class FilterList(FilterSettingsMixin):
         null=False
     )
     bypass_roles = ArrayField(
-        models.BigIntegerField(),
+        models.CharField(max_length=100),
         help_text="Roles and users who can bypass this filter.",
+        validators=(validate_bypass_roles_field,),
         null=False
     )
     enabled = models.BooleanField(
         help_text="Whether this filter is currently enabled.",
         null=False
     )
+    send_alert = models.BooleanField(
+        help_text="Whether alert should be sent.",
+        null=False,
+        default=True
+    )
+    server_message_text = models.CharField(
+        max_length=100,
+        help_text="The message to send on the server",
+        null=True
+    )
+    server_message_embed = models.CharField(
+        max_length=100,
+        help_text="The content of the server message embed",
+        null=True
+    )
     # Where a filter should apply.
     #
     # The resolution is done in the following order:
@@ -145,14 +170,29 @@ class Filter(FilterSettingsMixin):
         null=True
     )
     bypass_roles = ArrayField(
-        models.BigIntegerField(),
+        models.CharField(max_length=100),
         help_text="Roles and users who can bypass this filter.",
+        validators=(validate_bypass_roles_field,),
         null=True
     )
     enabled = models.BooleanField(
         help_text="Whether this filter is currently enabled.",
         null=True
     )
+    send_alert = models.BooleanField(
+        help_text="Whether alert should be sent.",
+        null=True
+    )
+    server_message_text = models.CharField(
+        max_length=100,
+        help_text="The message to send on the server",
+        null=True
+    )
+    server_message_embed = models.CharField(
+        max_length=100,
+        help_text="The content of the server message embed",
+        null=True
+    )
 
     # Check FilterList model for information about these properties.
     enabled_channels = ArrayField(models.IntegerField(), null=True)
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 784f8160..30af9512 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -139,7 +139,8 @@ BASE_SETTINGS_FIELDS = (
     "bypass_roles",
     "filter_dm",
     "enabled",
-    "delete_messages"
+    "delete_messages",
+    "send_alert"
 )
 INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration")
 CHANNEL_SCOPE_FIELDS = (
@@ -147,6 +148,7 @@ CHANNEL_SCOPE_FIELDS = (
     "disabled_categories",
     "enabled_channels",
 )
+SERVER_MESSAGE_FIELDS = ("server_message_text", "server_message_embed")
 MENTIONS_FIELDS = ("ping_type", "dm_ping_type")
 
 SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS
@@ -214,10 +216,16 @@ class FilterSerializer(ModelSerializer):
                     "mentions":
                         {
                             schema_field_name: getattr(instance, schema_field_name)
-                            for schema_field_name in MENTIONS_FIELDS}
+                            for schema_field_name in MENTIONS_FIELDS
+                        }
                 }
+        } | {
+            "server_message":
+            {
+                schema_field_name: getattr(instance, schema_field_name)
+                for schema_field_name in SERVER_MESSAGE_FIELDS
+            }
         }
-
         schema_base = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS} | \
                       {"filter_list": instance.filter_list.id}
 
@@ -307,6 +315,11 @@ class FilterListSerializer(ModelSerializer):
                 schema_field_name: getattr(instance, schema_field_name)
                 for schema_field_name in MENTIONS_FIELDS
             }
+        } | {
+            "server_message": {
+                schema_field_name: getattr(instance, schema_field_name)
+                for schema_field_name in SERVER_MESSAGE_FIELDS
+            }
         }
         return schema_base | {"settings": schema_settings_base | schema_settings_categories}
 
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
index 20af079d..e52cd4e5 100644
--- a/pydis_site/apps/api/viewsets/bot/filters.py
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -22,20 +22,21 @@ class FilterListViewSet(ModelViewSet):
     >>> [
     ...     {
     ...         "id": 1,
-    ...         "name": "guild_invite",
+    ...         "name": "invites",
     ...         "list_type": 1,
     ...         "filters": [
     ...             {
     ...                 "id": 1,
-    ...                 "filter_list": 1
     ...                 "content": "267624335836053506",
     ...                 "description": "Python Discord",
     ...                 "additional_field": None,
+    ...                 "filter_list": 1
     ...                 "settings": {
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
-    ...                        "enabled": False
-    ...                        "delete_messages": True
+    ...                        "enabled": None
+    ...                        "send_alert": True,
+    ...                        "delete_messages": None
     ...                        "infraction": {
     ...                            "infraction_type": None,
     ...                            "infraction_reason": "",
@@ -50,37 +51,42 @@ class FilterListViewSet(ModelViewSet):
     ...                            "ping_type": None
     ...                            "dm_ping_type": None
     ...                         }
+    ...                        "server_message": {
+    ...                            "server_message_text": None,
+    ...                            "server_message_embed": None
+    ...                        }
     ...                    }
     ...
     ...             },
     ...             ...
     ...         ],
     ...            "settings": {
-    ...              "ping_type": [
-    ...                  "onduty"
-    ...              ],
-    ...              "dm_ping_type": [
-    ...                  "onduty"
-    ...              ],
     ...              "bypass_roles": [
-    ...                  267630620367257601
+    ...                  "staff"
     ...              ],
     ...              "filter_dm": True,
-    ...              "enabled": False
-    ...              "delete_messages": True
+    ...              "enabled": True
+    ...              "delete_messages": True,
+    ...              "send_alert": True
     ...              "infraction": {
-    ...                   "infraction_type": None,
+    ...                   "infraction_type": "",
     ...                   "infraction_reason": "",
-    ...                   "infraction_duration": None,
+    ...                   "infraction_duration": "0.0",
     ...               }
     ...               "channel_scope": {
-    ...                 "disabled_channels": None,
-    ...                 "disabled_categories": None,
-    ...                 "enabled_channels": None
-    ...                }
+    ...                 "disabled_channels": [],
+    ...                 "disabled_categories": [],
+    ...                 "enabled_channels": []
+    ...               }
     ...               "mentions": {
-    ...                 "ping_type": None
-    ...                 "dm_ping_type": None
+    ...                 "ping_type": [
+    ...                     "onduty"
+    ...                 ]
+    ...                 "dm_ping_type": []
+    ...                }
+    ...                "server_message": {
+    ...                 "server_message_text": "",
+    ...                 "server_message_embed": ""
     ...                }
     ...           },
     ...     ...
@@ -96,7 +102,7 @@ class FilterListViewSet(ModelViewSet):
     #### Response format
     >>> {
     ...         "id": 1,
-    ...         "name": "guild_invite",
+    ...         "name": "invites",
     ...         "list_type": 1,
     ...         "filters": [
     ...             {
@@ -108,8 +114,9 @@ class FilterListViewSet(ModelViewSet):
     ...                 "settings": {
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
-    ...                        "enabled": False
-    ...                        "delete_messages": True
+    ...                        "enabled": None
+    ...                        "delete_messages": None,
+    ...                        "send_alert": None
     ...                        "infraction": {
     ...                            "infraction_type": None,
     ...                            "infraction_reason": "",
@@ -124,37 +131,42 @@ class FilterListViewSet(ModelViewSet):
     ...                            "ping_type": None
     ...                            "dm_ping_type": None
     ...                         }
+    ...                         "server_message": {
+    ...                             "server_message_text": None,
+    ...                             "server_message_embed": None
+    ...                         }
     ...                    }
     ...
     ...             },
     ...             ...
     ...         ],
     ...            "settings": {
-    ...              "ping_type": [
-    ...                  "onduty"
-    ...              ],
-    ...              "dm_ping_type": [
-    ...                  "onduty"
-    ...              ],
     ...              "bypass_roles": [
-    ...                  267630620367257601
+    ...                  "staff"
     ...              ],
     ...              "filter_dm": True,
-    ...              "enabled": False
+    ...              "enabled": True
     ...              "delete_messages": True
+    ...              "send_alert": True
     ...              "infraction": {
-    ...                   "infraction_type": None,
+    ...                   "infraction_type": "",
     ...                   "infraction_reason": "",
-    ...                   "infraction_duration": None,
+    ...                   "infraction_duration": "0.0",
     ...               }
     ...               "channel_scope": {
-    ...                 "disabled_channels": None,
-    ...                 "disabled_categories": None,
-    ...                 "enabled_channels": None
+    ...                 "disabled_channels": [],
+    ...                 "disabled_categories": [],
+    ...                 "enabled_channels": []
     ...                }
     ...               "mentions": {
-    ...                 "ping_type": None
-    ...                 "dm_ping_type": None
+    ...                 "ping_type": [
+    ...                     "onduty"
+    ...                 ]
+    ...                 "dm_ping_type": []
+    ...                }
+    ...               "server_message": {
+    ...                 "server_message_text": "",
+    ...                 "server_message_embed": ""
     ...                }
     ... }
 
@@ -193,11 +205,12 @@ class FilterViewSet(ModelViewSet):
     ...                 "settings": {
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
-    ...                        "enabled": False
-    ...                        "delete_messages": True
+    ...                        "enabled": None
+    ...                        "delete_messages": True,
+    ...                        "send_alert": True
     ...                        "infraction": {
     ...                            "infraction_type": None,
-    ...                            "infraction_reason": "",
+    ...                            "infraction_reason": None,
     ...                            "infraction_duration": None
     ...                        },
     ...                        "channel_scope": {
@@ -231,11 +244,12 @@ class FilterViewSet(ModelViewSet):
     ...                 "settings": {
     ...                        "bypass_roles": None
     ...                        "filter_dm": None,
-    ...                        "enabled": False
-    ...                        "delete_messages": True
+    ...                        "enabled": None
+    ...                        "delete_messages": True,
+    ...                        "send_alert": True
     ...                        "infraction": {
     ...                            "infraction_type": None,
-    ...                            "infraction_reason": "",
+    ...                            "infraction_reason": None,
     ...                            "infraction_duration": None
     ...                        },
     ...                        "channel_scope": {
-- 
cgit v1.2.3
From c2aaa8d672484a698b8aec6a65c2f4af3cff18b1 Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Fri, 24 Dec 2021 14:15:52 +0100
Subject: Include 'dm_content ' field under Infraction settings in
 Filters/FilterLists
---
 pydis_site/apps/api/serializers.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 30af9512..66236d92 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -142,7 +142,7 @@ BASE_SETTINGS_FIELDS = (
     "delete_messages",
     "send_alert"
 )
-INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration")
+INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration", "dm_content")
 CHANNEL_SCOPE_FIELDS = (
     "disabled_channels",
     "disabled_categories",
-- 
cgit v1.2.3
From 466fc7cc4297fe4f5d921f6ca950b926ecc2d14d Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Fri, 24 Dec 2021 14:22:29 +0100
Subject: Correct 'Redirect' FilterLists' default values.
---
 .../0075_prepare_filter_and_filterlist_for_new_filter_schema.py         | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
index cc524fcb..56cbdedb 100644
--- a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
@@ -26,7 +26,7 @@ def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> N
         list_type=0,
         filter_dm=True,
         delete_messages=False,
-        bypass_roles=[0],
+        bypass_roles=["staff"],
         enabled=True
     )
     redirects.save()
-- 
cgit v1.2.3
From c082ad818608fd52238e61f9c69d99cfb2aa503b Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Sat, 25 Dec 2021 20:18:02 +0200
Subject: Merged infraction and notification settings in JSON
The settings for infracting and notifying the user were merged under one field, which is renamed to "infraction_and_notification".
The only place which sends a message in the server by default is the antimalware, the rest try to DM the user first, and antimalware can do the same.
This avoids complications which may result from the filtering cog trying to send two messages: one for the defined server message, and another for a failed DM.
---
 .../0079_add_server_message_and_alert_fields.py    | 22 ++++-----------
 pydis_site/apps/api/models/bot/filters.py          | 25 ++++-------------
 pydis_site/apps/api/serializers.py                 | 28 ++++++++-----------
 pydis_site/apps/api/viewsets/bot/filters.py        | 32 ++++++++--------------
 4 files changed, 34 insertions(+), 73 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py
index f9803bd3..c6299cb9 100644
--- a/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py
+++ b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py
@@ -15,8 +15,7 @@ def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> N
     }
     for filter_list in FilterList.objects.all():
         filter_list.send_alert = change_map.get(filter_list.name)
-        filter_list.server_message_text = ""
-        filter_list.server_message_embed = ""
+        filter_list.dm_embed = ""
         filter_list.save()
 
 
@@ -24,7 +23,6 @@ def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) ->
     FilterList = apps.get_model("api", "FilterList")
     for filter_list in FilterList.objects.all():
         filter_list.send_alert = True
-        filter_list.server_message_text = None
         filter_list.server_message_embed = None
         filter_list.save()
 
@@ -42,13 +40,8 @@ class Migration(migrations.Migration):
         ),
         migrations.AddField(
             model_name='filter',
-            name='server_message_embed',
-            field=models.CharField(help_text='The content of the server message embed', max_length=100, null=True),
-        ),
-        migrations.AddField(
-            model_name='filter',
-            name='server_message_text',
-            field=models.CharField(help_text='The message to send on the server', max_length=100, null=True),
+            name='dm_embed',
+            field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True),
         ),
         migrations.AddField(
             model_name='filterlist',
@@ -57,13 +50,8 @@ class Migration(migrations.Migration):
         ),
         migrations.AddField(
             model_name='filterlist',
-            name='server_message_embed',
-            field=models.CharField(help_text='The content of the server message embed', max_length=100, null=True),
-        ),
-        migrations.AddField(
-            model_name='filterlist',
-            name='server_message_text',
-            field=models.CharField(help_text='The message to send on the server', max_length=100, null=True),
+            name='dm_embed',
+            field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True),
         ),
         migrations.RunPython(migrate_filterlist, unmigrate_filterlist)
     ]
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 92251ee4..97af21f8 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -50,6 +50,11 @@ class FilterSettingsMixin(models.Model):
         null=True,
         help_text="The DM to send to a user triggering this filter."
     )
+    dm_embed = models.CharField(
+        max_length=2000,
+        help_text="The content of the DM embed",
+        null=True
+    )
     infraction_type = models.CharField(
         choices=Infraction.TYPE_CHOICES,
         max_length=9,
@@ -111,16 +116,6 @@ class FilterList(FilterSettingsMixin):
         null=False,
         default=True
     )
-    server_message_text = models.CharField(
-        max_length=100,
-        help_text="The message to send on the server",
-        null=True
-    )
-    server_message_embed = models.CharField(
-        max_length=100,
-        help_text="The content of the server message embed",
-        null=True
-    )
     # Where a filter should apply.
     #
     # The resolution is done in the following order:
@@ -183,16 +178,6 @@ class Filter(FilterSettingsMixin):
         help_text="Whether alert should be sent.",
         null=True
     )
-    server_message_text = models.CharField(
-        max_length=100,
-        help_text="The message to send on the server",
-        null=True
-    )
-    server_message_embed = models.CharField(
-        max_length=100,
-        help_text="The content of the server message embed",
-        null=True
-    )
 
     # Check FilterList model for information about these properties.
     enabled_channels = ArrayField(models.IntegerField(), null=True)
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 66236d92..91aac822 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -142,13 +142,18 @@ BASE_SETTINGS_FIELDS = (
     "delete_messages",
     "send_alert"
 )
-INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration", "dm_content")
+INFRACTION_AND_NOTIFICATION_FIELDS = (
+    "infraction_type",
+    "infraction_reason",
+    "infraction_duration",
+    "dm_content",
+    "dm_embed"
+)
 CHANNEL_SCOPE_FIELDS = (
     "disabled_channels",
     "disabled_categories",
     "enabled_channels",
 )
-SERVER_MESSAGE_FIELDS = ("server_message_text", "server_message_embed")
 MENTIONS_FIELDS = ("ping_type", "dm_ping_type")
 
 SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS
@@ -208,8 +213,10 @@ class FilterSerializer(ModelSerializer):
         schema_settings = {
             "settings":
                 {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
-                | {"infraction": {name: getattr(instance, name) for name in INFRACTION_FIELDS}}
                 | {
+                    "infraction_and_notification":
+                        {name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS}
+                } | {
                     "channel_scope":
                         {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}
                 } | {
@@ -219,12 +226,6 @@ class FilterSerializer(ModelSerializer):
                             for schema_field_name in MENTIONS_FIELDS
                         }
                 }
-        } | {
-            "server_message":
-            {
-                schema_field_name: getattr(instance, schema_field_name)
-                for schema_field_name in SERVER_MESSAGE_FIELDS
-            }
         }
         schema_base = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS} | \
                       {"filter_list": instance.filter_list.id}
@@ -306,8 +307,8 @@ class FilterListSerializer(ModelSerializer):
             | {"filters": filters}
         schema_settings_base = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
         schema_settings_categories = {
-            "infraction":
-            {name: getattr(instance, name) for name in INFRACTION_FIELDS}} \
+            "infraction_and_notification":
+            {name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS}} \
             | {
             "channel_scope":
             {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}} | {
@@ -315,11 +316,6 @@ class FilterListSerializer(ModelSerializer):
                 schema_field_name: getattr(instance, schema_field_name)
                 for schema_field_name in MENTIONS_FIELDS
             }
-        } | {
-            "server_message": {
-                schema_field_name: getattr(instance, schema_field_name)
-                for schema_field_name in SERVER_MESSAGE_FIELDS
-            }
         }
         return schema_base | {"settings": schema_settings_base | schema_settings_categories}
 
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
index e52cd4e5..dd9a7d87 100644
--- a/pydis_site/apps/api/viewsets/bot/filters.py
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -37,10 +37,12 @@ class FilterListViewSet(ModelViewSet):
     ...                        "enabled": None
     ...                        "send_alert": True,
     ...                        "delete_messages": None
-    ...                        "infraction": {
+    ...                        "infraction_and_notification": {
     ...                            "infraction_type": None,
     ...                            "infraction_reason": "",
     ...                            "infraction_duration": None
+    ...                            "dm_content": None,
+    ...                            "dm_embed": None
     ...                        },
     ...                        "channel_scope": {
     ...                            "disabled_channels": None,
@@ -51,10 +53,6 @@ class FilterListViewSet(ModelViewSet):
     ...                            "ping_type": None
     ...                            "dm_ping_type": None
     ...                         }
-    ...                        "server_message": {
-    ...                            "server_message_text": None,
-    ...                            "server_message_embed": None
-    ...                        }
     ...                    }
     ...
     ...             },
@@ -68,10 +66,12 @@ class FilterListViewSet(ModelViewSet):
     ...              "enabled": True
     ...              "delete_messages": True,
     ...              "send_alert": True
-    ...              "infraction": {
+    ...              "infraction_and_notification": {
     ...                   "infraction_type": "",
     ...                   "infraction_reason": "",
     ...                   "infraction_duration": "0.0",
+    ...                   "dm_content": "",
+    ...                   "dm_embed": ""
     ...               }
     ...               "channel_scope": {
     ...                 "disabled_channels": [],
@@ -84,10 +84,6 @@ class FilterListViewSet(ModelViewSet):
     ...                 ]
     ...                 "dm_ping_type": []
     ...                }
-    ...                "server_message": {
-    ...                 "server_message_text": "",
-    ...                 "server_message_embed": ""
-    ...                }
     ...           },
     ...     ...
     ... ]
@@ -117,10 +113,12 @@ class FilterListViewSet(ModelViewSet):
     ...                        "enabled": None
     ...                        "delete_messages": None,
     ...                        "send_alert": None
-    ...                        "infraction": {
+    ...                        "infraction_and_notification": {
     ...                            "infraction_type": None,
     ...                            "infraction_reason": "",
     ...                            "infraction_duration": None
+    ...                            "dm_content": None,
+    ...                            "dm_embed": None
     ...                        },
     ...                        "channel_scope": {
     ...                            "disabled_channels": None,
@@ -131,10 +129,6 @@ class FilterListViewSet(ModelViewSet):
     ...                            "ping_type": None
     ...                            "dm_ping_type": None
     ...                         }
-    ...                         "server_message": {
-    ...                             "server_message_text": None,
-    ...                             "server_message_embed": None
-    ...                         }
     ...                    }
     ...
     ...             },
@@ -148,10 +142,12 @@ class FilterListViewSet(ModelViewSet):
     ...              "enabled": True
     ...              "delete_messages": True
     ...              "send_alert": True
-    ...              "infraction": {
+    ...              "infraction_and_notification": {
     ...                   "infraction_type": "",
     ...                   "infraction_reason": "",
     ...                   "infraction_duration": "0.0",
+    ...                   "dm_content": "",
+    ...                   "dm_embed": ""
     ...               }
     ...               "channel_scope": {
     ...                 "disabled_channels": [],
@@ -164,10 +160,6 @@ class FilterListViewSet(ModelViewSet):
     ...                 ]
     ...                 "dm_ping_type": []
     ...                }
-    ...               "server_message": {
-    ...                 "server_message_text": "",
-    ...                 "server_message_embed": ""
-    ...                }
     ... }
 
     #### Status codes
-- 
cgit v1.2.3
From 78e91c433b193682d82bbeecd6e73c2b01964b3d Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Sat, 1 Jan 2022 14:41:36 +0200
Subject: Merge migrations and correct filter defaults to be all null
---
 .../apps/api/migrations/0070_new_filter_schema.py  |  4 +-
 .../0079_add_server_message_and_alert_fields.py    | 57 ---------------------
 .../migrations/0079_dm_embed_and_alert_fields.py   | 58 ++++++++++++++++++++++
 3 files changed, 60 insertions(+), 59 deletions(-)
 delete mode 100644 pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py
 create mode 100644 pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index f56c29f8..2c15605c 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -60,7 +60,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
                 enabled=None,
                 dm_content=None,
                 infraction_type=None,
-                infraction_reason="",
+                infraction_reason=None,
                 infraction_duration=None,
                 disallowed_channels=None,
                 disallowed_categories=None,
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
                 ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)),
                 ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
                 ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)),
-                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)),
+                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True)),
                 ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
                 ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
                 ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
diff --git a/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py
deleted file mode 100644
index c6299cb9..00000000
--- a/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# Generated by Django 3.1.14 on 2021-12-19 23:05
-from django.apps.registry import Apps
-from django.db import migrations, models
-from django.db.backends.base.schema import BaseDatabaseSchemaEditor
-
-
-def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
-    FilterList = apps.get_model("api", "FilterList")
-    change_map = {
-        "tokens": True,
-        "domains": True,
-        "invites": True,
-        "extensions": False,
-        "redirects": False
-    }
-    for filter_list in FilterList.objects.all():
-        filter_list.send_alert = change_map.get(filter_list.name)
-        filter_list.dm_embed = ""
-        filter_list.save()
-
-
-def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
-    FilterList = apps.get_model("api", "FilterList")
-    for filter_list in FilterList.objects.all():
-        filter_list.send_alert = True
-        filter_list.server_message_embed = None
-        filter_list.save()
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ('api', '0078_merge_20211218_2200'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='filter',
-            name='send_alert',
-            field=models.BooleanField(help_text='Whether alert should be sent.', null=True),
-        ),
-        migrations.AddField(
-            model_name='filter',
-            name='dm_embed',
-            field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True),
-        ),
-        migrations.AddField(
-            model_name='filterlist',
-            name='send_alert',
-            field=models.BooleanField(default=True, help_text='Whether alert should be sent.'),
-        ),
-        migrations.AddField(
-            model_name='filterlist',
-            name='dm_embed',
-            field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True),
-        ),
-        migrations.RunPython(migrate_filterlist, unmigrate_filterlist)
-    ]
diff --git a/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py
new file mode 100644
index 00000000..49da62b6
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py
@@ -0,0 +1,58 @@
+# Generated by Django 3.1.14 on 2021-12-19 23:05
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+    FilterList = apps.get_model("api", "FilterList")
+    change_map = {
+        "tokens": True,
+        "domains": True,
+        "invites": True,
+        "extensions": False,
+        "redirects": False
+    }
+    for filter_list in FilterList.objects.all():
+        filter_list.send_alert = change_map.get(filter_list.name)
+        filter_list.dm_embed = ""
+        filter_list.save()
+
+
+def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+    FilterList = apps.get_model("api", "FilterList")
+    for filter_list in FilterList.objects.all():
+        filter_list.send_alert = True
+        filter_list.server_message_embed = None
+        filter_list.save()
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('api', '0078_merge_20211213_0552'),
+        ('api', '0078_merge_20211218_2200'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='filter',
+            name='send_alert',
+            field=models.BooleanField(help_text='Whether alert should be sent.', null=True),
+        ),
+        migrations.AddField(
+            model_name='filter',
+            name='dm_embed',
+            field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True),
+        ),
+        migrations.AddField(
+            model_name='filterlist',
+            name='send_alert',
+            field=models.BooleanField(default=True, help_text='Whether alert should be sent.'),
+        ),
+        migrations.AddField(
+            model_name='filterlist',
+            name='dm_embed',
+            field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True),
+        ),
+        migrations.RunPython(migrate_filterlist, unmigrate_filterlist)
+    ]
-- 
cgit v1.2.3
From 7cbc5dc1cfce844a9620ba902a7d576079e58c06 Mon Sep 17 00:00:00 2001
From: Steele 
Date: Fri, 11 Feb 2022 00:37:02 -0500
Subject: Drop "in Python" from title.
While the title of the book is "Neural Networks from Scratch in Python", it's typically referred to as "Neural Networks from Scratch", and it goes without saying in this context that Python will be used in code examples.
---
 .../resources/resources/neural_networks_from_scratch_in_python.yaml     | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml b/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml
index c4ad1e1b..26e88cb9 100644
--- a/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml
+++ b/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml
@@ -2,7 +2,7 @@ description: '"Neural Networks From Scratch" is a book intended to teach you how
   without any libraries, so you can better understand deep learning and how all of the elements work.
   This is so you can go out and do new/novel things with deep learning as well as to become more successful with even more basic models.
   This book is to accompany the usual free tutorial videos and sample code from youtube.com/sentdex.'
-name: Neural Networks from Scratch in Python
+name: Neural Networks from Scratch
 title_url: https://nnfs.io/
 urls:
   - icon: branding/goodreads
-- 
cgit v1.2.3
From 28fbcbf81cea41df0686a409cf71a45121899b7c Mon Sep 17 00:00:00 2001
From: Steele 
Date: Fri, 11 Feb 2022 00:46:52 -0500
Subject: Add PyCharm logo.
Same URL used in `jetbrains_videos.yaml`.
---
 pydis_site/apps/resources/resources/pycharm.yaml | 1 +
 1 file changed, 1 insertion(+)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/resources/resources/pycharm.yaml b/pydis_site/apps/resources/resources/pycharm.yaml
index 574158bc..e8c787e6 100644
--- a/pydis_site/apps/resources/resources/pycharm.yaml
+++ b/pydis_site/apps/resources/resources/pycharm.yaml
@@ -1,6 +1,7 @@
 description: The very best Python IDE, with a wealth of advanced features and convenience
   functions.
 name: PyCharm
+title_image: https://resources.jetbrains.com/storage/products/pycharm/img/meta/pycharm_logo_300x300.png
 title_url: https://www.jetbrains.com/pycharm/
 tags:
   topics:
-- 
cgit v1.2.3
From f30e1d9e4fc420085a1187fa12ac23efccd21663 Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Tue, 15 Feb 2022 22:05:20 +0200
Subject: Allow filter descriptions to be null
---
 pydis_site/apps/api/migrations/0070_new_filter_schema.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
index 2c15605c..f33c112b 100644
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
@@ -50,7 +50,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
             new_object = filter_.objects.create(
                 content=object_.content,
                 filter_list=list_,
-                description=object_.comment or "",
+                description=object_.comment,
                 additional_field=None,
                 ping_type=None,
                 filter_dm=None,
@@ -86,7 +86,7 @@ class Migration(migrations.Migration):
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('content', models.CharField(help_text='The definition of this filter.', max_length=100)),
-                ('description', models.CharField(help_text='Why this filter has been added.', max_length=200)),
+                ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)),
                 ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)),
                 ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
                 ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)),
-- 
cgit v1.2.3
From a28cdded7dabb62d639125dca2320234263809c2 Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Tue, 15 Feb 2022 22:11:40 +0200
Subject: Use singular nouns for filter list names
---
 .../0075_prepare_filter_and_filterlist_for_new_filter_schema.py   | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
index 56cbdedb..2a85fa63 100644
--- a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
@@ -7,10 +7,10 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
     FilterList = apps.get_model("api", "FilterList")
     change_map = {
-        "filter_token": "tokens",
-        "domain_name": "domains",
-        "guild_invite": "invites",
-        "file_format": "extensions"
+        "filter_token": "token",
+        "domain_name": "domain",
+        "guild_invite": "invite",
+        "file_format": "extension"
     }
     for filter_list in FilterList.objects.all():
         if change_map.get(filter_list.name):
-- 
cgit v1.2.3
From 7d22d8427fa73e6209ffcea827d9e460b6c1d985 Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Tue, 15 Feb 2022 22:59:04 +0100
Subject: Patch a minor issue with FilterList field naming in migrations
---
 ...75_prepare_filter_and_filterlist_for_new_filter_schema.py | 12 ++++++------
 .../apps/api/migrations/0079_dm_embed_and_alert_fields.py    | 10 +++++-----
 pydis_site/apps/api/serializers.py                           |  3 ++-
 3 files changed, 13 insertions(+), 12 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
index 2a85fa63..1e24b379 100644
--- a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
@@ -17,7 +17,7 @@ def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> N
             filter_list.name = change_map.get(filter_list.name)
             filter_list.save()
     redirects = FilterList(
-        name="redirects",
+        name="redirect",
         ping_type=[],
         dm_ping_type=[],
         enabled_channels=[],
@@ -35,16 +35,16 @@ def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> N
 def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
     FilterList = apps.get_model("api", "FilterList")
     change_map = {
-        "tokens": "filter_token",
-        "domains": "domain_name",
-        "invites": "guild_invite",
-        "formats": "file_format"
+        "token": "filter_token",
+        "domain": "domain_name",
+        "invite": "guild_invite",
+        "format": "file_format"
     }
     for filter_list in FilterList.objects.all():
         if change_map.get(filter_list.name):
             filter_list.name = change_map.get(filter_list.name)
             filter_list.save()
-    FilterList.objects.filter(name="redirects").delete()
+    FilterList.objects.filter(name="redirect").delete()
 
 
 class Migration(migrations.Migration):
diff --git a/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py
index 49da62b6..cae175df 100644
--- a/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py
+++ b/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py
@@ -7,11 +7,11 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
     FilterList = apps.get_model("api", "FilterList")
     change_map = {
-        "tokens": True,
-        "domains": True,
-        "invites": True,
-        "extensions": False,
-        "redirects": False
+        "token": True,
+        "domain": True,
+        "invite": True,
+        "extension": False,
+        "redirect": False
     }
     for filter_list in FilterList.objects.all():
         filter_list.send_alert = change_map.get(filter_list.name)
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 88b6e2bd..99f2b630 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -215,7 +215,8 @@ class FilterSerializer(ModelSerializer):
                 {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
                 | {
                     "infraction_and_notification":
-                        {name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS}
+                        {name: getattr(instance, name)
+                         for name in INFRACTION_AND_NOTIFICATION_FIELDS}
                 } | {
                     "channel_scope":
                         {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}
-- 
cgit v1.2.3
From 01ccc1dac80cc2958849d5be90255294f38878fb Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Tue, 22 Feb 2022 20:44:24 +0200
Subject: Changed channeld fields to char arrays, merged migrations
- The fields concerning channels were changed to contains strings instead of integers in order to allow specifying channels and categories by name.
The migrations were merged into a single migration.
---
 .../apps/api/migrations/0070_new_filter_schema.py  | 145 -------------------
 .../api/migrations/0074_merge_20211017_0822.py     |  14 --
 ..._filter_and_filterlist_for_new_filter_schema.py |  95 -------------
 .../api/migrations/0078_merge_20211218_2200.py     |  14 --
 .../migrations/0079_dm_embed_and_alert_fields.py   |  58 --------
 .../apps/api/migrations/0079_new_filter_schema.py  | 156 +++++++++++++++++++++
 6 files changed, 156 insertions(+), 326 deletions(-)
 delete mode 100644 pydis_site/apps/api/migrations/0070_new_filter_schema.py
 delete mode 100644 pydis_site/apps/api/migrations/0074_merge_20211017_0822.py
 delete mode 100644 pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
 delete mode 100644 pydis_site/apps/api/migrations/0078_merge_20211218_2200.py
 delete mode 100644 pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py
 create mode 100644 pydis_site/apps/api/migrations/0079_new_filter_schema.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py
deleted file mode 100644
index f33c112b..00000000
--- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py
+++ /dev/null
@@ -1,145 +0,0 @@
-# Modified migration file to migrate existing filters to the new one
-from datetime import timedelta
-
-import django.contrib.postgres.fields
-from django.apps.registry import Apps
-from django.db import migrations, models
-import django.db.models.deletion
-from django.db.backends.base.schema import BaseDatabaseSchemaEditor
-
-import pydis_site.apps.api.models.bot.filters
-
-OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY'))
-
-
-def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
-    filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
-    filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
-    filter_list_old = apps.get_model("api", "FilterListOld")
-
-    for name, type_ in OLD_LIST_NAMES:
-        objects = filter_list_old.objects.filter(type=name)
-        if name == "DOMAIN_NAME":
-            dm_content = "Your URL has been removed because it matched a blacklisted domain: {match}"
-        elif name == "GUILD_INVITE":
-            dm_content = "Per Rule 6, your invite link has been removed. " \
-                         "Our server rules can be found here: https://pythondiscord.com/pages/rules"
-        else:
-            dm_content = ""
-
-        list_ = filter_list.objects.create(
-            name=name.lower(),
-            list_type=1 if type_ == "ALLOW" else 0,
-            ping_type=(["onduty"] if name != "FILE_FORMAT" else []),
-            filter_dm=True,
-            dm_ping_type=[],
-            delete_messages=(True if name != "FILTER_TOKEN" else False),
-            bypass_roles=["staff"],
-            enabled=True,
-            dm_content=dm_content,
-            infraction_type="",
-            infraction_reason="",
-            infraction_duration=timedelta(seconds=0),
-            disallowed_channels=[],
-            disallowed_categories=[],
-            allowed_channels=[],
-            allowed_categories=[]
-        )
-
-        for object_ in objects:
-            new_object = filter_.objects.create(
-                content=object_.content,
-                filter_list=list_,
-                description=object_.comment,
-                additional_field=None,
-                ping_type=None,
-                filter_dm=None,
-                dm_ping_type=None,
-                delete_messages=None,
-                bypass_roles=None,
-                enabled=None,
-                dm_content=None,
-                infraction_type=None,
-                infraction_reason=None,
-                infraction_duration=None,
-                disallowed_channels=None,
-                disallowed_categories=None,
-                allowed_channels=None,
-                allowed_categories=None
-            )
-            new_object.save()
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('api', '0069_documentationlink_validators'),
-    ]
-
-    operations = [
-        migrations.RenameModel(
-            old_name='FilterList',
-            new_name='FilterListOld'
-        ),
-        migrations.CreateModel(
-            name='Filter',
-            fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('content', models.CharField(help_text='The definition of this filter.', max_length=100)),
-                ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)),
-                ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)),
-                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
-                ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)),
-                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
-                ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)),
-                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field], null=True)),
-                ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)),
-                ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
-                ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)),
-                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True)),
-                ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
-                ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
-                ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
-                ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
-                ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)),
-            ],
-        ),
-        migrations.CreateModel(
-            name='FilterList',
-            fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('name', models.CharField(help_text='The unique name of this list.', max_length=50)),
-                ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')),
-                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
-                ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')),
-                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
-                ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')),
-                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field])),
-                ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')),
-                ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
-                ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)),
-                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)),
-                ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
-                ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-                ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)),
-            ],
-        ),
-        migrations.AddField(
-            model_name='filter',
-            name='filter_list',
-            field=models.ForeignKey(help_text='The filter list containing this filter.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='api.FilterList'),
-        ),
-        migrations.AddConstraint(
-            model_name='filterlist',
-            constraint=models.UniqueConstraint(fields=('name', 'list_type'), name='unique_name_type'),
-        ),
-        migrations.RunPython(
-            code=forward,  # Core of the migration
-            reverse_code=lambda *_: None
-        ),
-        migrations.DeleteModel(
-            name='FilterListOld'
-        )
-    ]
diff --git a/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py b/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py
deleted file mode 100644
index ae41ac71..00000000
--- a/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py
+++ /dev/null
@@ -1,14 +0,0 @@
-# Generated by Django 3.0.14 on 2021-10-17 08:22
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('api', '0073_otn_allow_GT_and_LT'),
-        ('api', '0070_new_filter_schema'),
-    ]
-
-    operations = [
-    ]
diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
deleted file mode 100644
index 1e24b379..00000000
--- a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py
+++ /dev/null
@@ -1,95 +0,0 @@
-# Generated by Django 3.0.14 on 2021-12-11 23:14
-from django.apps.registry import Apps
-from django.db import migrations, models
-from django.db.backends.base.schema import BaseDatabaseSchemaEditor
-
-
-def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
-    FilterList = apps.get_model("api", "FilterList")
-    change_map = {
-        "filter_token": "token",
-        "domain_name": "domain",
-        "guild_invite": "invite",
-        "file_format": "extension"
-    }
-    for filter_list in FilterList.objects.all():
-        if change_map.get(filter_list.name):
-            filter_list.name = change_map.get(filter_list.name)
-            filter_list.save()
-    redirects = FilterList(
-        name="redirect",
-        ping_type=[],
-        dm_ping_type=[],
-        enabled_channels=[],
-        disabled_channels=[],
-        disabled_categories=[],
-        list_type=0,
-        filter_dm=True,
-        delete_messages=False,
-        bypass_roles=["staff"],
-        enabled=True
-    )
-    redirects.save()
-
-
-def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
-    FilterList = apps.get_model("api", "FilterList")
-    change_map = {
-        "token": "filter_token",
-        "domain": "domain_name",
-        "invite": "guild_invite",
-        "format": "file_format"
-    }
-    for filter_list in FilterList.objects.all():
-        if change_map.get(filter_list.name):
-            filter_list.name = change_map.get(filter_list.name)
-            filter_list.save()
-    FilterList.objects.filter(name="redirect").delete()
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ('api', '0074_merge_20211017_0822'),
-    ]
-
-    operations = [
-        migrations.RenameField(
-            model_name='filter',
-            old_name='allowed_categories',
-            new_name='disabled_categories',
-        ),
-        migrations.RenameField(
-            model_name='filter',
-            old_name='allowed_channels',
-            new_name='disabled_channels',
-        ),
-        migrations.RenameField(
-            model_name='filter',
-            old_name='disallowed_channels',
-            new_name='enabled_channels',
-        ),
-        migrations.RenameField(
-            model_name='filterlist',
-            old_name='allowed_categories',
-            new_name='disabled_categories',
-        ),
-        migrations.RenameField(
-            model_name='filterlist',
-            old_name='allowed_channels',
-            new_name='disabled_channels',
-        ),
-        migrations.RenameField(
-            model_name='filterlist',
-            old_name='disallowed_channels',
-            new_name='enabled_channels',
-        ),
-        migrations.RemoveField(
-            model_name='filterlist',
-            name='disallowed_categories',
-        ),
-        migrations.RemoveField(
-            model_name='filter',
-            name='disallowed_categories',
-        ),
-        migrations.RunPython(migrate_filterlist, unmigrate_filterlist)
-    ]
diff --git a/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py b/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py
deleted file mode 100644
index 7fe559f5..00000000
--- a/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py
+++ /dev/null
@@ -1,14 +0,0 @@
-# Generated by Django 3.1.14 on 2021-12-18 22:00
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('api', '0075_prepare_filter_and_filterlist_for_new_filter_schema'),
-        ('api', '0077_use_generic_jsonfield'),
-    ]
-
-    operations = [
-    ]
diff --git a/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py
deleted file mode 100644
index cae175df..00000000
--- a/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# Generated by Django 3.1.14 on 2021-12-19 23:05
-from django.apps.registry import Apps
-from django.db import migrations, models
-from django.db.backends.base.schema import BaseDatabaseSchemaEditor
-
-
-def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
-    FilterList = apps.get_model("api", "FilterList")
-    change_map = {
-        "token": True,
-        "domain": True,
-        "invite": True,
-        "extension": False,
-        "redirect": False
-    }
-    for filter_list in FilterList.objects.all():
-        filter_list.send_alert = change_map.get(filter_list.name)
-        filter_list.dm_embed = ""
-        filter_list.save()
-
-
-def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
-    FilterList = apps.get_model("api", "FilterList")
-    for filter_list in FilterList.objects.all():
-        filter_list.send_alert = True
-        filter_list.server_message_embed = None
-        filter_list.save()
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ('api', '0078_merge_20211213_0552'),
-        ('api', '0078_merge_20211218_2200'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='filter',
-            name='send_alert',
-            field=models.BooleanField(help_text='Whether alert should be sent.', null=True),
-        ),
-        migrations.AddField(
-            model_name='filter',
-            name='dm_embed',
-            field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True),
-        ),
-        migrations.AddField(
-            model_name='filterlist',
-            name='send_alert',
-            field=models.BooleanField(default=True, help_text='Whether alert should be sent.'),
-        ),
-        migrations.AddField(
-            model_name='filterlist',
-            name='dm_embed',
-            field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True),
-        ),
-        migrations.RunPython(migrate_filterlist, unmigrate_filterlist)
-    ]
diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
new file mode 100644
index 00000000..94494186
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
@@ -0,0 +1,156 @@
+# Modified migration file to migrate existing filters to the new one
+from datetime import timedelta
+
+import django.contrib.postgres.fields
+from django.apps.registry import Apps
+from django.db import migrations, models
+import django.db.models.deletion
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+import pydis_site.apps.api.models.bot.filters
+
+OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY'), ('REDIRECT', 'DENY'))
+change_map = {
+    "FILTER_TOKEN": "token",
+    "DOMAIN_NAME": "domain",
+    "GUILD_INVITE": "invite",
+    "FILE_FORMAT": "extension",
+    "REDIRECT": "redirect"
+}
+
+
+def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+    filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
+    filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
+    filter_list_old = apps.get_model("api", "FilterListOld")
+
+    for name, type_ in OLD_LIST_NAMES:
+        objects = filter_list_old.objects.filter(type=name)
+        if name == "DOMAIN_NAME":
+            dm_content = "Your URL has been removed because it matched a blacklisted domain: {match}"
+        elif name == "GUILD_INVITE":
+            dm_content = "Per Rule 6, your invite link has been removed. " \
+                         "Our server rules can be found here: https://pythondiscord.com/pages/rules"
+        else:
+            dm_content = ""
+
+        list_ = filter_list.objects.create(
+            name=change_map[name],
+            list_type=1 if type_ == "ALLOW" else 0,
+            ping_type=(["Moderators"] if name != "FILE_FORMAT" else []),
+            filter_dm=True,
+            dm_ping_type=[],
+            delete_messages=(True if name != "FILTER_TOKEN" else False),
+            bypass_roles=["Helpers"],
+            enabled=True,
+            dm_content=dm_content,
+            dm_embed="",
+            infraction_type="",
+            infraction_reason="",
+            infraction_duration=timedelta(seconds=0),
+            disabled_channels=[],
+            disabled_categories=(["CODE JAM"] if name in ("FILE_FORMAT", "GUILD_INVITE") else []),
+            enabled_channels=[],
+            send_alert=(name in ('GUILD_INVITE', 'DOMAIN_NAME', 'FILTER_TOKEN'))
+        )
+
+        for object_ in objects:
+            new_object = filter_.objects.create(
+                content=object_.content,
+                filter_list=list_,
+                description=object_.comment,
+                additional_field=None,
+                ping_type=None,
+                filter_dm=None,
+                dm_ping_type=None,
+                delete_messages=None,
+                bypass_roles=None,
+                enabled=None,
+                dm_content=None,
+                dm_embed=None,
+                infraction_type=None,
+                infraction_reason=None,
+                infraction_duration=None,
+                disabled_channels=None,
+                disabled_categories=None,
+                enabled_channels=None,
+                send_alert=None,
+            )
+            new_object.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0078_merge_20211213_0552'),
+    ]
+
+    operations = [
+        migrations.RenameModel(
+            old_name='FilterList',
+            new_name='FilterListOld'
+        ),
+        migrations.CreateModel(
+            name='Filter',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('content', models.CharField(help_text='The definition of this filter.', max_length=100)),
+                ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)),
+                ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)),
+                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
+                ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)),
+                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
+                ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)),
+                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field], null=True)),
+                ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)),
+                ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
+                ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)),
+                ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)),
+                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True)),
+                ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
+                ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter.", null=True, size=None)),
+                ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", null=True, size=None)),
+                ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", null=True, size=None)),
+                ('send_alert', models.BooleanField(help_text='Whether an alert should be sent.', null=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='FilterList',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(help_text='The unique name of this list.', max_length=50)),
+                ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')),
+                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
+                ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')),
+                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
+                ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')),
+                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field])),
+                ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')),
+                ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
+                ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)),
+                ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)),
+                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)),
+                ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
+                ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter.", size=None)),
+                ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", size=None)),
+                ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", size=None)),
+                ('send_alert', models.BooleanField(help_text='Whether an alert should be sent.')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='filter',
+            name='filter_list',
+            field=models.ForeignKey(help_text='The filter list containing this filter.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='api.FilterList'),
+        ),
+        migrations.AddConstraint(
+            model_name='filterlist',
+            constraint=models.UniqueConstraint(fields=('name', 'list_type'), name='unique_name_type'),
+        ),
+        migrations.RunPython(
+            code=forward,  # Core of the migration
+            reverse_code=lambda *_: None
+        ),
+        migrations.DeleteModel(
+            name='FilterListOld'
+        )
+    ]
-- 
cgit v1.2.3
From 37f9296322d5aaef6aefc68eb97e6e1d5c0df531 Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Wed, 23 Feb 2022 23:51:05 +0200
Subject: Extensions list is ALLOW, not DENY
---
 pydis_site/apps/api/migrations/0079_new_filter_schema.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
index 94494186..4728ea91 100644
--- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
@@ -9,7 +9,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 
 import pydis_site.apps.api.models.bot.filters
 
-OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY'), ('REDIRECT', 'DENY'))
+OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'ALLOW'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY'), ('REDIRECT', 'DENY'))
 change_map = {
     "FILTER_TOKEN": "token",
     "DOMAIN_NAME": "domain",
-- 
cgit v1.2.3
From a2fcfdf8fd80fc4cfd89be19ffb18a3c1799d2cb Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Thu, 24 Feb 2022 21:14:35 +0200
Subject: Create placeholder value for dm embed content in ext list
Some value is needed to signal the bot a message should be sent for a blocked extension. The value itself will be changed at runtime, but this allows avoiding the bot code delving into the exact API response format.
---
 pydis_site/apps/api/migrations/0079_new_filter_schema.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
index 4728ea91..58ed0025 100644
--- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
@@ -44,7 +44,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
             bypass_roles=["Helpers"],
             enabled=True,
             dm_content=dm_content,
-            dm_embed="",
+            dm_embed="" if name != "FILE_FORMAT" else "*Defined at runtime.*",
             infraction_type="",
             infraction_reason="",
             infraction_duration=timedelta(seconds=0),
-- 
cgit v1.2.3
From b0f4b93ee831d0873f134440a6554177cc043feb Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Sat, 26 Feb 2022 00:53:28 +0200
Subject: Add invites denylist to the migration
---
 pydis_site/apps/api/migrations/0079_new_filter_schema.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
index 58ed0025..43915edb 100644
--- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
@@ -9,7 +9,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 
 import pydis_site.apps.api.models.bot.filters
 
-OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'ALLOW'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY'), ('REDIRECT', 'DENY'))
+OLD_LIST_NAMES = (('GUILD_INVITE', True), ('GUILD_INVITE', False), ('FILE_FORMAT', True), ('DOMAIN_NAME', False), ('FILTER_TOKEN', False), ('REDIRECT', False))
 change_map = {
     "FILTER_TOKEN": "token",
     "DOMAIN_NAME": "domain",
@@ -25,7 +25,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
     filter_list_old = apps.get_model("api", "FilterListOld")
 
     for name, type_ in OLD_LIST_NAMES:
-        objects = filter_list_old.objects.filter(type=name)
+        objects = filter_list_old.objects.filter(type=name, allowed=type_)
         if name == "DOMAIN_NAME":
             dm_content = "Your URL has been removed because it matched a blacklisted domain: {match}"
         elif name == "GUILD_INVITE":
@@ -36,7 +36,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
 
         list_ = filter_list.objects.create(
             name=change_map[name],
-            list_type=1 if type_ == "ALLOW" else 0,
+            list_type=int(type_),
             ping_type=(["Moderators"] if name != "FILE_FORMAT" else []),
             filter_dm=True,
             dm_ping_type=[],
-- 
cgit v1.2.3
From 02ea9e97f68e5388f7c3ade6ec48b11b272018bf Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Tue, 1 Mar 2022 23:49:52 +0200
Subject: Refine DM content for domains
---
 pydis_site/apps/api/migrations/0079_new_filter_schema.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
index 43915edb..89f70799 100644
--- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
@@ -27,7 +27,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
     for name, type_ in OLD_LIST_NAMES:
         objects = filter_list_old.objects.filter(type=name, allowed=type_)
         if name == "DOMAIN_NAME":
-            dm_content = "Your URL has been removed because it matched a blacklisted domain: {match}"
+            dm_content = "Your message has been removed because it contained a blocked domain: `{domain}`."
         elif name == "GUILD_INVITE":
             dm_content = "Per Rule 6, your invite link has been removed. " \
                          "Our server rules can be found here: https://pythondiscord.com/pages/rules"
-- 
cgit v1.2.3
From a6b8c27e68b529b1060b1213b465457c5c0d685a Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Mon, 7 Mar 2022 20:18:18 +0100
Subject: Add support for storing AoC related data in site
---
 .../apps/api/migrations/0080_add_aoc_tables.py     | 33 +++++++++++
 pydis_site/apps/api/models/__init__.py             |  2 +
 pydis_site/apps/api/models/bot/__init__.py         |  2 +
 .../apps/api/models/bot/aoc_completionist_block.py | 21 +++++++
 pydis_site/apps/api/models/bot/aoc_link.py         | 20 +++++++
 pydis_site/apps/api/serializers.py                 | 22 +++++++
 pydis_site/apps/api/urls.py                        | 10 ++++
 pydis_site/apps/api/viewsets/__init__.py           |  2 +
 pydis_site/apps/api/viewsets/bot/__init__.py       |  2 +
 .../api/viewsets/bot/aoc_completionist_block.py    | 69 ++++++++++++++++++++++
 pydis_site/apps/api/viewsets/bot/aoc_link.py       | 69 ++++++++++++++++++++++
 11 files changed, 252 insertions(+)
 create mode 100644 pydis_site/apps/api/migrations/0080_add_aoc_tables.py
 create mode 100644 pydis_site/apps/api/models/bot/aoc_completionist_block.py
 create mode 100644 pydis_site/apps/api/models/bot/aoc_link.py
 create mode 100644 pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
 create mode 100644 pydis_site/apps/api/viewsets/bot/aoc_link.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
new file mode 100644
index 00000000..f129d86f
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.1.14 on 2022-03-06 16:07
+
+from django.db import migrations, models
+import django.db.models.deletion
+import pydis_site.apps.api.models.mixins
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0079_merge_20220125_2022'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AocCompletionistBlock',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')),
+                ('user', models.ForeignKey(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')),
+            ],
+            bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
+        ),
+        migrations.CreateModel(
+            name='AocAccountLink',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('aoc_username', models.CharField(help_text='The AoC username associated with the Discord User.', max_length=120)),
+                ('user', models.ForeignKey(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')),
+            ],
+            bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
+        ),
+    ]
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index fd5bf220..4f616986 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -10,6 +10,8 @@ from .bot import (
     Nomination,
     NominationEntry,
     OffensiveMessage,
+    AocAccountLink,
+    AocCompletionistBlock,
     OffTopicChannelName,
     Reminder,
     Role,
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index ac864de3..ec0e701c 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -5,6 +5,8 @@ from .deleted_message import DeletedMessage
 from .documentation_link import DocumentationLink
 from .infraction import Infraction
 from .message import Message
+from .aoc_completionist_block import AocCompletionistBlock
+from .aoc_link import AocAccountLink
 from .message_deletion_context import MessageDeletionContext
 from .nomination import Nomination, NominationEntry
 from .off_topic_channel_name import OffTopicChannelName
diff --git a/pydis_site/apps/api/models/bot/aoc_completionist_block.py b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
new file mode 100644
index 00000000..cac41ff1
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
@@ -0,0 +1,21 @@
+from django.db import models
+
+from pydis_site.apps.api.models.bot.user import User
+from pydis_site.apps.api.models.mixins import ModelReprMixin
+
+
+class AocCompletionistBlock(ModelReprMixin, models.Model):
+    """A Discord user blocked from getting the AoC completionist Role."""
+
+    user = models.ForeignKey(
+        User,
+        on_delete=models.CASCADE,
+        help_text="The user that is blocked from getting the AoC Completionist Role"
+    )
+
+    is_blocked = models.BooleanField(
+        default=True,
+        help_text="Whether this user is actively being blocked "
+                  "from getting the AoC Completionist Role",
+        verbose_name="Blocked"
+    )
diff --git a/pydis_site/apps/api/models/bot/aoc_link.py b/pydis_site/apps/api/models/bot/aoc_link.py
new file mode 100644
index 00000000..6c7cc591
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/aoc_link.py
@@ -0,0 +1,20 @@
+from django.db import models
+
+from pydis_site.apps.api.models.bot.user import User
+from pydis_site.apps.api.models.mixins import ModelReprMixin
+
+
+class AocAccountLink(ModelReprMixin, models.Model):
+    """An AoC account link for a Discord User."""
+
+    user = models.ForeignKey(
+        User,
+        on_delete=models.CASCADE,
+        help_text="The user that is blocked from getting the AoC Completionist Role"
+    )
+
+    aoc_username = models.CharField(
+        max_length=120,
+        help_text="The AoC username associated with the Discord User.",
+        blank=False
+    )
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 745aff42..0b0e4237 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -13,6 +13,8 @@ from rest_framework.settings import api_settings
 from rest_framework.validators import UniqueTogetherValidator
 
 from .models import (
+    AocAccountLink,
+    AocCompletionistBlock,
     BotSetting,
     DeletedMessage,
     DocumentationLink,
@@ -250,6 +252,26 @@ class ReminderSerializer(ModelSerializer):
         )
 
 
+class AocCompletionistBlockSerializer(ModelSerializer):
+    """A class providing (de-)serialization of `AocCompletionistBlock` instances."""
+
+    class Meta:
+        """Metadata defined for the Django REST Framework."""
+
+        model = AocCompletionistBlock
+        fields = ("user", "is_blocked")
+
+
+class AocAccountLinkSerializer(ModelSerializer):
+    """A class providing (de-)serialization of `AocAccountLink` instances."""
+
+    class Meta:
+        """Metadata defined for the Django REST Framework."""
+
+        model = AocAccountLink
+        fields = ("user", "aoc_username")
+
+
 class RoleSerializer(ModelSerializer):
     """A class providing (de-)serialization of `Role` instances."""
 
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index b0ab545b..7c55fc92 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -3,6 +3,8 @@ from rest_framework.routers import DefaultRouter
 
 from .views import HealthcheckView, RulesView
 from .viewsets import (
+    AocAccountLinkViewSet,
+    AocCompletionistBlockViewSet,
     BotSettingViewSet,
     DeletedMessageViewSet,
     DocumentationLinkViewSet,
@@ -34,6 +36,14 @@ bot_router.register(
     'documentation-links',
     DocumentationLinkViewSet
 )
+bot_router.register(
+    "aoc-account-links",
+    AocAccountLinkViewSet
+)
+bot_router.register(
+    "aoc-completionist-blocks",
+    AocCompletionistBlockViewSet
+)
 bot_router.register(
     'infractions',
     InfractionViewSet
diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
index f133e77f..5fc1d64f 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -7,6 +7,8 @@ from .bot import (
     InfractionViewSet,
     NominationViewSet,
     OffensiveMessageViewSet,
+    AocAccountLinkViewSet,
+    AocCompletionistBlockViewSet,
     OffTopicChannelNameViewSet,
     ReminderViewSet,
     RoleViewSet,
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
index 84b87eab..f1d84729 100644
--- a/pydis_site/apps/api/viewsets/bot/__init__.py
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -7,6 +7,8 @@ from .infraction import InfractionViewSet
 from .nomination import NominationViewSet
 from .off_topic_channel_name import OffTopicChannelNameViewSet
 from .offensive_message import OffensiveMessageViewSet
+from .aoc_link import AocAccountLinkViewSet
+from .aoc_completionist_block import AocCompletionistBlockViewSet
 from .reminder import ReminderViewSet
 from .role import RoleViewSet
 from .user import UserViewSet
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
new file mode 100644
index 00000000..53bcb546
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
@@ -0,0 +1,69 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.mixins import (
+    CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import AocCompletionistBlock
+from pydis_site.apps.api.serializers import AocCompletionistBlockSerializer
+
+
+class AocCompletionistBlockViewSet(
+    GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin
+):
+    """
+    View providing management for Users blocked from gettign the AoC completionist Role.
+
+    ## Routes
+
+    ### GET /bot/aoc-completionist-blocks/
+    Returns all the AoC completionist blocks
+
+    #### Response format
+    >>> [
+    ...     {
+    ...         "user": 2,
+    ...         "is_blocked": False
+    ...     }
+    ... ]
+
+
+    ### GET /bot/aoc-completionist-blocks/
+    Retrieve a single Block by User ID
+
+    #### Response format
+    >>>
+    ...     {
+    ...         "user": 2,
+    ...         "is_blocked": False
+    ...     }
+
+    #### Status codes
+    - 200: returned on success
+    - 404: returned if an AoC completionist block with the given user__id was not found.
+
+    ### POST /bot/aoc-completionist-blocks
+    Adds a single AoC completionist block
+
+    #### Request body
+    >>> {
+    ... 'user': int,
+    ... 'is_blocked': bool
+    ... }
+
+    #### Status codes
+    - 204: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### DELETE /bot/aoc-completionist-blocks/
+    Deletes the AoC Completionist block item with the given `user__id`.
+    #### Status codes
+    - 204: returned on success
+    - 404: if the AoC Completionist block with the given user__id does not exist
+
+    """
+
+    serializer_class = AocCompletionistBlockSerializer
+    queryset = AocCompletionistBlock.objects.all()
+    filter_backends = (DjangoFilterBackend,)
+    filter_fields = ("user__id",)
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py
new file mode 100644
index 00000000..b5b5420e
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py
@@ -0,0 +1,69 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.mixins import (
+    CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import AocAccountLink
+from pydis_site.apps.api.serializers import AocAccountLinkSerializer
+
+
+class AocAccountLinkViewSet(
+    GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin
+):
+    """
+    View providing management for Users who linked their AoC accounts to their Discord Account.
+
+    ## Routes
+
+    ### GET /bot/aoc-account-links
+    Returns all the AoC account links
+
+    #### Response format
+    >>> [
+    ...     {
+    ...         "user": 2,
+    ...         "aoc_username": "AoCUser1"
+    ...     }
+    ... ]
+
+
+    ### GET /bot/aoc-account-links
+    Retrieve a AoC account link by User ID
+
+    #### Response format
+    >>>
+    ...     {
+    ...         "user": 2,
+    ...         "aoc_username": "AoCUser1"
+    ...     }
+
+    #### Status codes
+    - 200: returned on success
+    - 404: returned if an AoC account link with the given user__id was not found.
+
+    ### POST /bot/aoc-account-links
+    Adds a single AoC account link block
+
+    #### Request body
+    >>> {
+    ... 'user': int,
+    ... 'aoc_username': str
+    ... }
+
+    #### Status codes
+    - 204: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### DELETE /bot/aoc-account-links/
+    Deletes the AoC account link item with the given `user__id`.
+    #### Status codes
+    - 204: returned on success
+    - 404: if the AoC account link with the given user__id does not exist
+
+    """
+
+    serializer_class = AocAccountLinkSerializer
+    queryset = AocAccountLink.objects.all()
+    filter_backends = (DjangoFilterBackend,)
+    filter_fields = ("user__id",)
-- 
cgit v1.2.3
From 19c6e7b66cf5078a198f4a70fec38d66dd029564 Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Tue, 8 Mar 2022 15:54:34 +0100
Subject: Enhance comments and table structure in AoC related modules
- Set the user reference to be a OneToOne relation, on tables: AocCompletionistBlock and  AocAccountLink.
---
 pydis_site/apps/api/migrations/0080_add_aoc_tables.py       | 4 ++--
 pydis_site/apps/api/models/bot/aoc_completionist_block.py   | 2 +-
 pydis_site/apps/api/models/bot/aoc_link.py                  | 2 +-
 pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py | 4 ++--
 pydis_site/apps/api/viewsets/bot/aoc_link.py                | 4 ++--
 5 files changed, 8 insertions(+), 8 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
index f129d86f..c58a5d29 100644
--- a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
+++ b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
@@ -17,7 +17,7 @@ class Migration(migrations.Migration):
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')),
-                ('user', models.ForeignKey(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')),
+                ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')),
             ],
             bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
         ),
@@ -26,7 +26,7 @@ class Migration(migrations.Migration):
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('aoc_username', models.CharField(help_text='The AoC username associated with the Discord User.', max_length=120)),
-                ('user', models.ForeignKey(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')),
+                ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')),
             ],
             bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
         ),
diff --git a/pydis_site/apps/api/models/bot/aoc_completionist_block.py b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
index cac41ff1..a89f9760 100644
--- a/pydis_site/apps/api/models/bot/aoc_completionist_block.py
+++ b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
@@ -7,7 +7,7 @@ from pydis_site.apps.api.models.mixins import ModelReprMixin
 class AocCompletionistBlock(ModelReprMixin, models.Model):
     """A Discord user blocked from getting the AoC completionist Role."""
 
-    user = models.ForeignKey(
+    user = models.OneToOneField(
         User,
         on_delete=models.CASCADE,
         help_text="The user that is blocked from getting the AoC Completionist Role"
diff --git a/pydis_site/apps/api/models/bot/aoc_link.py b/pydis_site/apps/api/models/bot/aoc_link.py
index 6c7cc591..9b47456d 100644
--- a/pydis_site/apps/api/models/bot/aoc_link.py
+++ b/pydis_site/apps/api/models/bot/aoc_link.py
@@ -7,7 +7,7 @@ from pydis_site.apps.api.models.mixins import ModelReprMixin
 class AocAccountLink(ModelReprMixin, models.Model):
     """An AoC account link for a Discord User."""
 
-    user = models.ForeignKey(
+    user = models.OneToOneField(
         User,
         on_delete=models.CASCADE,
         help_text="The user that is blocked from getting the AoC Completionist Role"
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
index 53bcb546..c5568129 100644
--- a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
+++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
@@ -47,8 +47,8 @@ class AocCompletionistBlockViewSet(
 
     #### Request body
     >>> {
-    ... 'user': int,
-    ... 'is_blocked': bool
+    ...     'user': int,
+    ...     'is_blocked': bool
     ... }
 
     #### Status codes
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py
index b5b5420e..263b548d 100644
--- a/pydis_site/apps/api/viewsets/bot/aoc_link.py
+++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py
@@ -47,8 +47,8 @@ class AocAccountLinkViewSet(
 
     #### Request body
     >>> {
-    ... 'user': int,
-    ... 'aoc_username': str
+    ...     'user': int,
+    ...     'aoc_username': str
     ... }
 
     #### Status codes
-- 
cgit v1.2.3
From 955122d028b81529fffbf73f9298d0f06cb2e412 Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Wed, 9 Mar 2022 04:02:29 +0200
Subject: Change ping fields names
---
 pydis_site/apps/api/migrations/0079_new_filter_schema.py | 16 ++++++++--------
 pydis_site/apps/api/models/bot/filters.py                |  8 ++++----
 pydis_site/apps/api/serializers.py                       |  6 +++---
 3 files changed, 15 insertions(+), 15 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
index 89f70799..b67740d2 100644
--- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
@@ -37,9 +37,9 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
         list_ = filter_list.objects.create(
             name=change_map[name],
             list_type=int(type_),
-            ping_type=(["Moderators"] if name != "FILE_FORMAT" else []),
+            guild_pings=(["Moderators"] if name != "FILE_FORMAT" else []),
             filter_dm=True,
-            dm_ping_type=[],
+            dm_pings=[],
             delete_messages=(True if name != "FILTER_TOKEN" else False),
             bypass_roles=["Helpers"],
             enabled=True,
@@ -60,9 +60,9 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
                 filter_list=list_,
                 description=object_.comment,
                 additional_field=None,
-                ping_type=None,
+                guild_pings=None,
                 filter_dm=None,
-                dm_ping_type=None,
+                dm_pings=None,
                 delete_messages=None,
                 bypass_roles=None,
                 enabled=None,
@@ -97,9 +97,9 @@ class Migration(migrations.Migration):
                 ('content', models.CharField(help_text='The definition of this filter.', max_length=100)),
                 ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)),
                 ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)),
-                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
+                ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
                 ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)),
-                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
+                ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
                 ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)),
                 ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field], null=True)),
                 ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)),
@@ -120,9 +120,9 @@ class Migration(migrations.Migration):
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('name', models.CharField(help_text='The unique name of this list.', max_length=50)),
                 ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')),
-                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
+                ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
                 ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')),
-                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
+                ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
                 ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')),
                 ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field])),
                 ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')),
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 97af21f8..4dbf1875 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -84,14 +84,14 @@ class FilterList(FilterSettingsMixin):
         choices=FilterListType.choices,
         help_text="Whether this list is an allowlist or denylist"
     )
-    ping_type = ArrayField(
+    guild_pings = ArrayField(
         models.CharField(max_length=20),
         validators=(validate_ping_field,),
         help_text="Who to ping when this filter triggers.",
         null=False
     )
     filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False)
-    dm_ping_type = ArrayField(
+    dm_pings = ArrayField(
         models.CharField(max_length=20),
         validators=(validate_ping_field,),
         help_text="Who to ping when this filter triggers on a DM.",
@@ -147,14 +147,14 @@ class Filter(FilterSettingsMixin):
         FilterList, models.CASCADE, related_name="filters",
         help_text="The filter list containing this filter."
     )
-    ping_type = ArrayField(
+    guild_pings = ArrayField(
         models.CharField(max_length=20),
         validators=(validate_ping_field,),
         help_text="Who to ping when this filter triggers.",
         null=True
     )
     filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True)
-    dm_ping_type = ArrayField(
+    dm_pings = ArrayField(
         models.CharField(max_length=20),
         validators=(validate_ping_field,),
         help_text="Who to ping when this filter triggers on a DM.",
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 99f2b630..5a637976 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -121,9 +121,9 @@ ALWAYS_OPTIONAL_SETTINGS = (
 )
 
 REQUIRED_FOR_FILTER_LIST_SETTINGS = (
-    'ping_type',
+    'guild_pings',
     'filter_dm',
-    'dm_ping_type',
+    'dm_pings',
     'delete_messages',
     'bypass_roles',
     'enabled',
@@ -154,7 +154,7 @@ CHANNEL_SCOPE_FIELDS = (
     "disabled_categories",
     "enabled_channels",
 )
-MENTIONS_FIELDS = ("ping_type", "dm_ping_type")
+MENTIONS_FIELDS = ("guild_pings", "dm_pings")
 
 SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS
 
-- 
cgit v1.2.3
From 63fa1c03977435a2aa92325f8872e2d222d9e28a Mon Sep 17 00:00:00 2001
From: Krypton 
Date: Wed, 9 Mar 2022 15:42:05 +0100
Subject: Create discord-messages-with-colors.md
---
 .../python-guides/discord-messages-with-colors.md  | 64 ++++++++++++++++++++++
 1 file changed, 64 insertions(+)
 create mode 100644 pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
new file mode 100644
index 00000000..60ea8656
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
@@ -0,0 +1,64 @@
+---
+title: Discord messages with colors
+description: A guide on how to add colors to your codeblocks on Discord
+---
+
+Discord is now slowly rolling out the ability to send colored messages within code blocks. It uses the ANSI color codes, so if you've tried to print colored text in your terminal or console with Python or other languages then it will be easy for you.
+
+To be able to send a colored text, you need to use the `ansi` language for your code block and provide a prefix of this format before writing your text:
+```
+\u001b[{format};{color}m
+```
+*The `\u001b` is the unicode for ESCAPE/ESC, see .* ***If you want to use it yourself without bots, then you need to copy paste the character from the website.***
+
+After you've written this, you can type and text you wish, and if you want to reset the color back to normal, then you need to use `\u001b[0m` as prefix.
+
+Here is the list of values you can use to replace `{format}`:
+
+* 0: Normal
+* 1: **Bold**
+* 4: Underline
+
+Here is the list of values you can use to replace `{color}`:
+
+*The following values will change the **text** color.*
+
+* 30: Gray
+* 31: Red
+* 32: Green
+* 33: Yellow
+* 34: Blue
+* 35: Pink
+* 36: Cyan
+* 37: White
+
+*The following values will change the **text background** color.*
+
+* 40: Some very dark blue
+* 41: Orange
+* 42: Gray
+* 43: Light gray
+* 44: Even lighter gray
+* 45: Indigo
+* 46: Again some gray
+* 47: White
+
+Let's take an example, I want a bold green colored text with the very dark blue background.
+I simply use `\u001b[0;40m` (background color) and `\u001b[1;32m` (text color) as prefix. Note that the order is **important**, first you give the background color and then the text color.
+Alternatively you can also directly combine them into a single prefix like the following: `\u001b[1;40;32m` and you can also use multiple values. Something like `\u001b[1;40;4;32m` would underline the text, make it bold, make it green and have a dark blue background.
+
+Raw message:
+\`\`\`ansi
+\u001b[0;40m\u001b[1;32mThat's some cool formatted text right?
+or
+\u001b[1;40;32mThat's some cool formatted text right?
+\`\`\`
+
+Result:
+
+
+The way the colors look like on Discord is shown in the image below ^^
+
+Note: If the change as not been brought to you yet, or other users, then you can use other code blocks in the meantime to get colored text. See this gist: 
+
+
-- 
cgit v1.2.3
From b93dce5abcf225579b9407358f938ca3932e67a2 Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Wed, 9 Mar 2022 19:37:30 +0100
Subject: Add reason field to AoC completionist block table
---
 pydis_site/apps/api/migrations/0080_add_aoc_tables.py       | 1 +
 pydis_site/apps/api/models/bot/aoc_completionist_block.py   | 4 ++++
 pydis_site/apps/api/serializers.py                          | 2 +-
 pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py | 7 +++++--
 4 files changed, 11 insertions(+), 3 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
index c58a5d29..917c5b7f 100644
--- a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
+++ b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
@@ -17,6 +17,7 @@ class Migration(migrations.Migration):
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')),
+                ('reason', models.TextField(help_text='The reason for the AoC Completionist Role Block.', null=True)),
                 ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')),
             ],
             bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
diff --git a/pydis_site/apps/api/models/bot/aoc_completionist_block.py b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
index a89f9760..6605cbc4 100644
--- a/pydis_site/apps/api/models/bot/aoc_completionist_block.py
+++ b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
@@ -19,3 +19,7 @@ class AocCompletionistBlock(ModelReprMixin, models.Model):
                   "from getting the AoC Completionist Role",
         verbose_name="Blocked"
     )
+    reason = models.TextField(
+        null=True,
+        help_text="The reason for the AoC Completionist Role Block."
+    )
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 0b0e4237..c97f7dba 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -259,7 +259,7 @@ class AocCompletionistBlockSerializer(ModelSerializer):
         """Metadata defined for the Django REST Framework."""
 
         model = AocCompletionistBlock
-        fields = ("user", "is_blocked")
+        fields = ("user", "is_blocked", "reason")
 
 
 class AocAccountLinkSerializer(ModelSerializer):
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
index c5568129..8e7d821c 100644
--- a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
+++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
@@ -24,6 +24,7 @@ class AocCompletionistBlockViewSet(
     ...     {
     ...         "user": 2,
     ...         "is_blocked": False
+    ...         "reason": "Too good to be true"
     ...     }
     ... ]
 
@@ -36,6 +37,7 @@ class AocCompletionistBlockViewSet(
     ...     {
     ...         "user": 2,
     ...         "is_blocked": False
+    ...         "reason": "Too good to be true"
     ...     }
 
     #### Status codes
@@ -47,8 +49,9 @@ class AocCompletionistBlockViewSet(
 
     #### Request body
     >>> {
-    ...     'user': int,
-    ...     'is_blocked': bool
+    ...     "user": int,
+    ...     "is_blocked": bool
+    ...     "reason": string
     ... }
 
     #### Status codes
-- 
cgit v1.2.3
From d18d0198f2a43066b7f6cb9542a25adea6e6b3f4 Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Wed, 9 Mar 2022 23:07:38 +0100
Subject: Patch AoC tables to use the Discord user as PK.
---
 pydis_site/apps/api/migrations/0080_add_aoc_tables.py    | 16 +++++++---------
 .../apps/api/models/bot/aoc_completionist_block.py       |  3 ++-
 pydis_site/apps/api/models/bot/aoc_link.py               |  3 ++-
 pydis_site/apps/api/viewsets/bot/aoc_link.py             |  8 +++++---
 4 files changed, 16 insertions(+), 14 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
index 917c5b7f..2c0c689a 100644
--- a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
+++ b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
@@ -13,21 +13,19 @@ class Migration(migrations.Migration):
 
     operations = [
         migrations.CreateModel(
-            name='AocCompletionistBlock',
+            name='AocAccountLink',
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')),
-                ('reason', models.TextField(help_text='The reason for the AoC Completionist Role Block.', null=True)),
-                ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')),
+                ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='api.user')),
+                ('aoc_username', models.CharField(help_text='The AoC username associated with the Discord User.', max_length=120)),
             ],
             bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
         ),
         migrations.CreateModel(
-            name='AocAccountLink',
+            name='AocCompletionistBlock',
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('aoc_username', models.CharField(help_text='The AoC username associated with the Discord User.', max_length=120)),
-                ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')),
+                ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='api.user')),
+                ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')),
+                ('reason', models.TextField(help_text='The reason for the AoC Completionist Role Block.', null=True)),
             ],
             bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
         ),
diff --git a/pydis_site/apps/api/models/bot/aoc_completionist_block.py b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
index 6605cbc4..acbc0eba 100644
--- a/pydis_site/apps/api/models/bot/aoc_completionist_block.py
+++ b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
@@ -10,7 +10,8 @@ class AocCompletionistBlock(ModelReprMixin, models.Model):
     user = models.OneToOneField(
         User,
         on_delete=models.CASCADE,
-        help_text="The user that is blocked from getting the AoC Completionist Role"
+        help_text="The user that is blocked from getting the AoC Completionist Role",
+        primary_key=True
     )
 
     is_blocked = models.BooleanField(
diff --git a/pydis_site/apps/api/models/bot/aoc_link.py b/pydis_site/apps/api/models/bot/aoc_link.py
index 9b47456d..4e9d4882 100644
--- a/pydis_site/apps/api/models/bot/aoc_link.py
+++ b/pydis_site/apps/api/models/bot/aoc_link.py
@@ -10,7 +10,8 @@ class AocAccountLink(ModelReprMixin, models.Model):
     user = models.OneToOneField(
         User,
         on_delete=models.CASCADE,
-        help_text="The user that is blocked from getting the AoC Completionist Role"
+        help_text="The user that is blocked from getting the AoC Completionist Role",
+        primary_key=True
     )
 
     aoc_username = models.CharField(
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py
index 263b548d..c3fa6854 100644
--- a/pydis_site/apps/api/viewsets/bot/aoc_link.py
+++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py
@@ -24,7 +24,8 @@ class AocAccountLinkViewSet(
     ...     {
     ...         "user": 2,
     ...         "aoc_username": "AoCUser1"
-    ...     }
+    ...     },
+    ...     ...
     ... ]
 
 
@@ -32,11 +33,12 @@ class AocAccountLinkViewSet(
     Retrieve a AoC account link by User ID
 
     #### Response format
-    >>>
+    >>> [
     ...     {
     ...         "user": 2,
     ...         "aoc_username": "AoCUser1"
-    ...     }
+    ...     },
+    ... ]
 
     #### Status codes
     - 200: returned on success
-- 
cgit v1.2.3
From 934218e86b8e72ee999e7c086aa0ec7cabf08408 Mon Sep 17 00:00:00 2001
From: Krypton 
Date: Thu, 10 Mar 2022 10:26:23 +0100
Subject: Update discord-messages-with-colors.md
---
 .../resources/guides/python-guides/discord-messages-with-colors.md | 7 +++----
 1 file changed, 3 insertions(+), 4 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
index 60ea8656..30f40948 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
@@ -1,5 +1,5 @@
 ---
-title: Discord messages with colors
+title: Discord Messages with Colors
 description: A guide on how to add colors to your codeblocks on Discord
 ---
 
@@ -57,8 +57,7 @@ or
 Result:
 
 
-The way the colors look like on Discord is shown in the image below ^^
+The way the colors look like on Discord is shown in the image below:
+
 
 Note: If the change as not been brought to you yet, or other users, then you can use other code blocks in the meantime to get colored text. See this gist: 
-
-
-- 
cgit v1.2.3
From 8c95f13c96d16d6f4d0736ee136563d603926c63 Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Thu, 10 Mar 2022 17:28:39 +0100
Subject: Enhance code, documentation consistency in AoC related code
Co-authored-by: Mark <1515135+MarkKoz@users.noreply.github.com>
---
 pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py | 7 ++++---
 pydis_site/apps/api/viewsets/bot/aoc_link.py                | 1 +
 2 files changed, 5 insertions(+), 3 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
index 8e7d821c..d3167d7b 100644
--- a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
+++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
@@ -23,7 +23,7 @@ class AocCompletionistBlockViewSet(
     >>> [
     ...     {
     ...         "user": 2,
-    ...         "is_blocked": False
+    ...         "is_blocked": False,
     ...         "reason": "Too good to be true"
     ...     }
     ... ]
@@ -36,7 +36,7 @@ class AocCompletionistBlockViewSet(
     >>>
     ...     {
     ...         "user": 2,
-    ...         "is_blocked": False
+    ...         "is_blocked": False,
     ...         "reason": "Too good to be true"
     ...     }
 
@@ -50,7 +50,7 @@ class AocCompletionistBlockViewSet(
     #### Request body
     >>> {
     ...     "user": int,
-    ...     "is_blocked": bool
+    ...     "is_blocked": bool,
     ...     "reason": string
     ... }
 
@@ -60,6 +60,7 @@ class AocCompletionistBlockViewSet(
 
     ### DELETE /bot/aoc-completionist-blocks/
     Deletes the AoC Completionist block item with the given `user__id`.
+
     #### Status codes
     - 204: returned on success
     - 404: if the AoC Completionist block with the given user__id does not exist
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py
index c3fa6854..5f6f3a84 100644
--- a/pydis_site/apps/api/viewsets/bot/aoc_link.py
+++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py
@@ -59,6 +59,7 @@ class AocAccountLinkViewSet(
 
     ### DELETE /bot/aoc-account-links/
     Deletes the AoC account link item with the given `user__id`.
+
     #### Status codes
     - 204: returned on success
     - 404: if the AoC account link with the given user__id does not exist
-- 
cgit v1.2.3
From 05e2bce1e82e422755396d1e6e489d6792ec0115 Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Thu, 10 Mar 2022 20:56:47 +0200
Subject: Remove role validation
Roles can be either IDs or names, so the current validation is not relevant anymore. Furthermore the ping fields can accept user IDs or names.
---
 .../apps/api/migrations/0079_new_filter_schema.py  | 12 ++++----
 pydis_site/apps/api/models/bot/filters.py          | 32 ----------------------
 pydis_site/apps/api/tests/test_filters.py          | 11 --------
 3 files changed, 6 insertions(+), 49 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
index b67740d2..053f9782 100644
--- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
@@ -97,11 +97,11 @@ class Migration(migrations.Migration):
                 ('content', models.CharField(help_text='The definition of this filter.', max_length=100)),
                 ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)),
                 ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)),
-                ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
+                ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, null=True)),
                 ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)),
-                ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)),
+                ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, null=True)),
                 ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)),
-                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field], null=True)),
+                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, null=True)),
                 ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)),
                 ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
                 ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)),
@@ -120,11 +120,11 @@ class Migration(migrations.Migration):
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('name', models.CharField(help_text='The unique name of this list.', max_length=50)),
                 ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')),
-                ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
+                ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None)),
                 ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')),
-                ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])),
+                ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None)),
                 ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')),
-                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field])),
+                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None)),
                 ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')),
                 ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
                 ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)),
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 4dbf1875..13b332d2 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -16,32 +16,6 @@ class FilterListType(models.IntegerChoices):
     DENY = 0
 
 
-# Valid special values in ping related fields
-VALID_PINGS = ("everyone", "here", "moderators", "onduty", "admins")
-VALID_BYPASS_ROLES = ("staff",)
-
-
-def validate_ping_field(value_list: List[str]) -> None:
-    """Validate that the values are either a special value or a UID."""
-    for value in value_list:
-        # Check if it is a special value
-        if value in VALID_PINGS:
-            continue
-        # Check if it is a UID
-        if value.isnumeric():
-            continue
-
-        raise ValidationError(f"{value!r} isn't a valid ping type.")
-
-
-def validate_bypass_roles_field(value_list: List[str]) -> None:
-    """Validate that the vclues are either a special value or a Role ID."""
-    for value in value_list:
-        if value.isnumeric() or value in VALID_BYPASS_ROLES:
-            continue
-        raise ValidationError(f"{value!r} isn't a valid (bypass) role.")
-
-
 class FilterSettingsMixin(models.Model):
     """Mixin for common settings of a filters and filter lists."""
 
@@ -86,14 +60,12 @@ class FilterList(FilterSettingsMixin):
     )
     guild_pings = ArrayField(
         models.CharField(max_length=20),
-        validators=(validate_ping_field,),
         help_text="Who to ping when this filter triggers.",
         null=False
     )
     filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False)
     dm_pings = ArrayField(
         models.CharField(max_length=20),
-        validators=(validate_ping_field,),
         help_text="Who to ping when this filter triggers on a DM.",
         null=False
     )
@@ -104,7 +76,6 @@ class FilterList(FilterSettingsMixin):
     bypass_roles = ArrayField(
         models.CharField(max_length=100),
         help_text="Roles and users who can bypass this filter.",
-        validators=(validate_bypass_roles_field,),
         null=False
     )
     enabled = models.BooleanField(
@@ -149,14 +120,12 @@ class Filter(FilterSettingsMixin):
     )
     guild_pings = ArrayField(
         models.CharField(max_length=20),
-        validators=(validate_ping_field,),
         help_text="Who to ping when this filter triggers.",
         null=True
     )
     filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True)
     dm_pings = ArrayField(
         models.CharField(max_length=20),
-        validators=(validate_ping_field,),
         help_text="Who to ping when this filter triggers on a DM.",
         null=True
     )
@@ -167,7 +136,6 @@ class Filter(FilterSettingsMixin):
     bypass_roles = ArrayField(
         models.CharField(max_length=100),
         help_text="Roles and users who can bypass this filter.",
-        validators=(validate_bypass_roles_field,),
         null=True
     )
     enabled = models.BooleanField(
diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py
index f694053d..5f40c6f9 100644
--- a/pydis_site/apps/api/tests/test_filters.py
+++ b/pydis_site/apps/api/tests/test_filters.py
@@ -296,14 +296,3 @@ class GenericFilterTest(APISubdomainTestCase):
 
                 response = self.client.delete(f"{sequence.url()}/42")
                 self.assertEqual(response.status_code, 404)
-
-    def test_reject_invalid_ping(self) -> None:
-        url = reverse('bot:filteroverride-list', host='api')
-        data = {
-            "ping_type": ["invalid"]
-        }
-
-        response = self.client.post(url, data=data)
-
-        self.assertEqual(response.status_code, 400)
-        self.assertDictEqual(response.json(), {'ping_type': ["'invalid' isn't a valid ping type."]})
-- 
cgit v1.2.3
From 083cdf3f49805fd3c6d01fe538b7e01e12ca9b79 Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Thu, 10 Mar 2022 19:18:05 +0100
Subject: Add new filter field, and patch the docs in AoC viewsets
- Add the possibility to filter by `is_blocked` in the AoC completionist block viewset.
- Patch various tense, and formatting inconsistencies in AoC viewsets
---
 .../apps/api/viewsets/bot/aoc_completionist_block.py  |  6 +++---
 pydis_site/apps/api/viewsets/bot/aoc_link.py          | 19 +++++++++----------
 2 files changed, 12 insertions(+), 13 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
index d3167d7b..3a4cec60 100644
--- a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
+++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
@@ -42,7 +42,7 @@ class AocCompletionistBlockViewSet(
 
     #### Status codes
     - 200: returned on success
-    - 404: returned if an AoC completionist block with the given user__id was not found.
+    - 404: returned if an AoC completionist block with the given `user__id` was not found.
 
     ### POST /bot/aoc-completionist-blocks
     Adds a single AoC completionist block
@@ -63,11 +63,11 @@ class AocCompletionistBlockViewSet(
 
     #### Status codes
     - 204: returned on success
-    - 404: if the AoC Completionist block with the given user__id does not exist
+    - 404: returned if the AoC Completionist block with the given `user__id` was not found
 
     """
 
     serializer_class = AocCompletionistBlockSerializer
     queryset = AocCompletionistBlock.objects.all()
     filter_backends = (DjangoFilterBackend,)
-    filter_fields = ("user__id",)
+    filter_fields = ("user__id", "is_blocked")
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py
index 5f6f3a84..9f22c1a1 100644
--- a/pydis_site/apps/api/viewsets/bot/aoc_link.py
+++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py
@@ -29,20 +29,19 @@ class AocAccountLinkViewSet(
     ... ]
 
 
-    ### GET /bot/aoc-account-links
+    ### GET /bot/aoc-account-links/
     Retrieve a AoC account link by User ID
 
     #### Response format
-    >>> [
-    ...     {
-    ...         "user": 2,
-    ...         "aoc_username": "AoCUser1"
-    ...     },
-    ... ]
+    >>>
+    ... {
+    ...     "user": 2,
+    ...     "aoc_username": "AoCUser1"
+    ... }
 
     #### Status codes
     - 200: returned on success
-    - 404: returned if an AoC account link with the given user__id was not found.
+    - 404: returned if an AoC account link with the given `user__id` was not found.
 
     ### POST /bot/aoc-account-links
     Adds a single AoC account link block
@@ -55,14 +54,14 @@ class AocAccountLinkViewSet(
 
     #### Status codes
     - 204: returned on success
-    - 400: if one of the given fields is invalid
+    - 400: if one of the given fields was invalid
 
     ### DELETE /bot/aoc-account-links/
     Deletes the AoC account link item with the given `user__id`.
 
     #### Status codes
     - 204: returned on success
-    - 404: if the AoC account link with the given user__id does not exist
+    - 404: returned if the AoC account link with the given `user__id` was not found
 
     """
 
-- 
cgit v1.2.3
From c0666e5095c6e427b786fb934520d63fabaa8840 Mon Sep 17 00:00:00 2001
From: Johannes Christ 
Date: Sat, 12 Mar 2022 16:41:11 +0100
Subject: Add a README for the content directory
---
 pydis_site/apps/content/README.md | 37 +++++++++++++++++++++++++++++++++++++
 1 file changed, 37 insertions(+)
 create mode 100644 pydis_site/apps/content/README.md
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/README.md b/pydis_site/apps/content/README.md
new file mode 100644
index 00000000..b2719a9f
--- /dev/null
+++ b/pydis_site/apps/content/README.md
@@ -0,0 +1,37 @@
+# The "content" app
+
+This application serves static, markdown-based content. Django-wise there is
+relatively little code in there, most of it is concerned with serving our
+content.
+
+The markdown files hosting our content can be found in the
+[`resources/`](./resources) directory, and contain
+
+
+## Contributing pages
+
+Contributing pages is covered extensively in our online guide, which you can
+find
+[here](https://www.pythondiscord.com/pages/guides/pydis-guides/how-to-contribute-a-page/)
+or alternatively read it directly at
+[`resources/guides/pydis-guides/how-to-contribute-a-page.md`](./resources/guides/pydis-guides/how-to-contribute-a-page.md).
+
+
+## Directory structure
+
+Let's look at the structure in here:
+
+- `resources` contains the static Markdown files that make up our site's
+  [pages](https://www.pythondiscord.com/pages/)
+
+- `migrations` contains standard Django migrations. As the `content` app
+  contains purely static markdown files, no migrations are present here.
+
+- `tests` contains unit tests for verifying that the app works properly.
+
+- `views` contains our Django views generating and serving the pages from the
+  input markdown.
+
+As for the modules, apart from the standard Django modules in here, the
+`utils.py` module contains utility functions for discovering markdown files to
+serve on the filesystem.
-- 
cgit v1.2.3
From c7300a92c885b01a5663913fa73679fc680bfb74 Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Sat, 12 Mar 2022 16:48:54 +0100
Subject: Sync Filter models with relating migrations, adjust code consistency
---
 .../apps/api/migrations/0079_new_filter_schema.py  |  2 +-
 pydis_site/apps/api/models/bot/filters.py          | 57 ++++++++++++++--------
 2 files changed, 39 insertions(+), 20 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
index 053f9782..bd807f02 100644
--- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py
@@ -129,7 +129,7 @@ class Migration(migrations.Migration):
                 ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)),
                 ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)),
                 ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)),
-                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)),
+                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True)),
                 ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)),
                 ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter.", size=None)),
                 ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", size=None)),
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index 13b332d2..f8bbfd14 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -1,7 +1,4 @@
-from typing import List
-
 from django.contrib.postgres.fields import ArrayField, JSONField
-from django.core.exceptions import ValidationError
 from django.db import models
 from django.db.models import UniqueConstraint
 
@@ -37,7 +34,8 @@ class FilterSettingsMixin(models.Model):
     )
     infraction_reason = models.CharField(
         max_length=1000,
-        help_text="The reason to give for the infraction."
+        help_text="The reason to give for the infraction.",
+        null=True
     )
     infraction_duration = models.DurationField(
         null=True,
@@ -59,13 +57,13 @@ class FilterList(FilterSettingsMixin):
         help_text="Whether this list is an allowlist or denylist"
     )
     guild_pings = ArrayField(
-        models.CharField(max_length=20),
+        models.CharField(max_length=100),
         help_text="Who to ping when this filter triggers.",
         null=False
     )
     filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False)
     dm_pings = ArrayField(
-        models.CharField(max_length=20),
+        models.CharField(max_length=100),
         help_text="Who to ping when this filter triggers on a DM.",
         null=False
     )
@@ -83,9 +81,7 @@ class FilterList(FilterSettingsMixin):
         null=False
     )
     send_alert = models.BooleanField(
-        help_text="Whether alert should be sent.",
-        null=False,
-        default=True
+        help_text="Whether an alert should be sent.",
     )
     # Where a filter should apply.
     #
@@ -93,9 +89,18 @@ class FilterList(FilterSettingsMixin):
     #   - enabled_channels
     #   - disabled_categories
     #   - disabled_channels
-    enabled_channels = ArrayField(models.IntegerField())
-    disabled_channels = ArrayField(models.IntegerField())
-    disabled_categories = ArrayField(models.IntegerField())
+    enabled_channels = ArrayField(
+        models.CharField(max_length=100),
+        help_text="Channels in which to run the filter even if it's disabled in the category."
+    )
+    disabled_channels = ArrayField(
+        models.CharField(max_length=100),
+        help_text="Channels in which to not run the filter."
+    )
+    disabled_categories = ArrayField(
+        models.CharField(max_length=100),
+        help_text="Categories in which to not run the filter."
+    )
 
     class Meta:
         """Constrain name and list_type unique."""
@@ -112,20 +117,23 @@ class Filter(FilterSettingsMixin):
     """One specific trigger of a list."""
 
     content = models.CharField(max_length=100, help_text="The definition of this filter.")
-    description = models.CharField(max_length=200, help_text="Why this filter has been added.")
+    description = models.CharField(
+        max_length=200,
+        help_text="Why this filter has been added.", null=True
+    )
     additional_field = JSONField(null=True, help_text="Implementation specific field.")
     filter_list = models.ForeignKey(
         FilterList, models.CASCADE, related_name="filters",
         help_text="The filter list containing this filter."
     )
     guild_pings = ArrayField(
-        models.CharField(max_length=20),
+        models.CharField(max_length=100),
         help_text="Who to ping when this filter triggers.",
         null=True
     )
     filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True)
     dm_pings = ArrayField(
-        models.CharField(max_length=20),
+        models.CharField(max_length=100),
         help_text="Who to ping when this filter triggers on a DM.",
         null=True
     )
@@ -143,14 +151,25 @@ class Filter(FilterSettingsMixin):
         null=True
     )
     send_alert = models.BooleanField(
-        help_text="Whether alert should be sent.",
+        help_text="Whether an alert should be sent.",
         null=True
     )
 
     # Check FilterList model for information about these properties.
-    enabled_channels = ArrayField(models.IntegerField(), null=True)
-    disabled_channels = ArrayField(models.IntegerField(), null=True)
-    disabled_categories = ArrayField(models.IntegerField(), null=True)
+    enabled_channels = ArrayField(
+        models.CharField(max_length=100),
+        help_text="Channels in which to run the filter even if it's disabled in the category.",
+        null=True
+    )
+    disabled_channels = ArrayField(
+        models.CharField(max_length=100),
+        help_text="Channels in which to not run the filter.", null=True
+    )
+    disabled_categories = ArrayField(
+        models.CharField(max_length=100),
+        help_text="Categories in which to not run the filter.",
+        null=True
+    )
 
     def __str__(self) -> str:
         return f"Filter {self.content!r}"
-- 
cgit v1.2.3
From ca2c583b15d46d7821a03ec047678429f96c7e03 Mon Sep 17 00:00:00 2001
From: Johannes Christ 
Date: Sat, 12 Mar 2022 21:51:28 +0100
Subject: Reword documentation per review
Co-authored-by: MarkKoz 
---
 pydis_site/apps/content/README.md | 26 ++++++++++++--------------
 1 file changed, 12 insertions(+), 14 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/README.md b/pydis_site/apps/content/README.md
index b2719a9f..d36d6bee 100644
--- a/pydis_site/apps/content/README.md
+++ b/pydis_site/apps/content/README.md
@@ -1,19 +1,17 @@
 # The "content" app
 
-This application serves static, markdown-based content. Django-wise there is
-relatively little code in there, most of it is concerned with serving our
+This application serves static, Markdown-based content. Django-wise there is
+relatively little code in there; most of it is concerned with serving our
 content.
 
-The markdown files hosting our content can be found in the
-[`resources/`](./resources) directory, and contain
-
 
 ## Contributing pages
 
-Contributing pages is covered extensively in our online guide, which you can
-find
-[here](https://www.pythondiscord.com/pages/guides/pydis-guides/how-to-contribute-a-page/)
-or alternatively read it directly at
+The Markdown files hosting our content can be found in the
+[`resources/`](./resources) directory. The process of contributing to pages is
+covered extensively in our online guide which you can find
+[here](https://www.pythondiscord.com/pages/guides/pydis-guides/how-to-contribute-a-page/).
+Alternatively, read it directly at
 [`resources/guides/pydis-guides/how-to-contribute-a-page.md`](./resources/guides/pydis-guides/how-to-contribute-a-page.md).
 
 
@@ -25,13 +23,13 @@ Let's look at the structure in here:
   [pages](https://www.pythondiscord.com/pages/)
 
 - `migrations` contains standard Django migrations. As the `content` app
-  contains purely static markdown files, no migrations are present here.
+  contains purely static Markdown files, no migrations are present here.
 
 - `tests` contains unit tests for verifying that the app works properly.
 
-- `views` contains our Django views generating and serving the pages from the
-  input markdown.
+- `views` contains Django views which generating and serve the pages from the
+  input Markdown.
 
 As for the modules, apart from the standard Django modules in here, the
-`utils.py` module contains utility functions for discovering markdown files to
-serve on the filesystem.
+`utils.py` module contains utility functions for discovering Markdown files to
+serve.
-- 
cgit v1.2.3
From 4ad92b98a0df047b12e302ce8d849c2bd85862f1 Mon Sep 17 00:00:00 2001
From: Johannes Christ 
Date: Sat, 12 Mar 2022 21:53:53 +0100
Subject: Remove unused migrations package
---
 pydis_site/apps/content/README.md              | 3 ---
 pydis_site/apps/content/migrations/__init__.py | 0
 2 files changed, 3 deletions(-)
 delete mode 100644 pydis_site/apps/content/migrations/__init__.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/README.md b/pydis_site/apps/content/README.md
index d36d6bee..4149a6f0 100644
--- a/pydis_site/apps/content/README.md
+++ b/pydis_site/apps/content/README.md
@@ -22,9 +22,6 @@ Let's look at the structure in here:
 - `resources` contains the static Markdown files that make up our site's
   [pages](https://www.pythondiscord.com/pages/)
 
-- `migrations` contains standard Django migrations. As the `content` app
-  contains purely static Markdown files, no migrations are present here.
-
 - `tests` contains unit tests for verifying that the app works properly.
 
 - `views` contains Django views which generating and serve the pages from the
diff --git a/pydis_site/apps/content/migrations/__init__.py b/pydis_site/apps/content/migrations/__init__.py
deleted file mode 100644
index e69de29b..00000000
-- 
cgit v1.2.3
From 2bf88327c6298e9004aeda028576946b1704d799 Mon Sep 17 00:00:00 2001
From: Johannes Christ 
Date: Sat, 12 Mar 2022 22:43:52 +0100
Subject: Correct typo
Co-authored-by: Mark <1515135+MarkKoz@users.noreply.github.com>
---
 pydis_site/apps/content/README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/README.md b/pydis_site/apps/content/README.md
index 4149a6f0..e7061207 100644
--- a/pydis_site/apps/content/README.md
+++ b/pydis_site/apps/content/README.md
@@ -24,7 +24,7 @@ Let's look at the structure in here:
 
 - `tests` contains unit tests for verifying that the app works properly.
 
-- `views` contains Django views which generating and serve the pages from the
+- `views` contains Django views which generate and serve the pages from the
   input Markdown.
 
 As for the modules, apart from the standard Django modules in here, the
-- 
cgit v1.2.3
From a52a2cf631d92173f1a5573a56d3347d6456ac39 Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Sun, 13 Mar 2022 14:31:09 -0400
Subject: Update Project Lead color
---
 pydis_site/apps/content/resources/server-info/roles.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/server-info/roles.md b/pydis_site/apps/content/resources/server-info/roles.md
index 716f5b1e..2888f837 100644
--- a/pydis_site/apps/content/resources/server-info/roles.md
+++ b/pydis_site/apps/content/resources/server-info/roles.md
@@ -68,7 +68,7 @@ In addition to the informal descriptions below, we've also written down a more f
 ###  Domain Leads
 **Description:** Staff in charge of a certain domain such as moderation, events, and outreach. A lead will have a second role specifying their domain.
 
-###  Project Leads
+###  Project Leads
 **Description:** Staff in charge of a certain project that require special attention, such as a YouTube video series or our new forms page.
 
 ###  Moderators
-- 
cgit v1.2.3
From 06079418f5610a63c84dcc6c979ade347c6c5451 Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Sun, 13 Mar 2022 14:40:14 -0400
Subject: Add Events Team
---
 pydis_site/apps/content/resources/server-info/roles.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/server-info/roles.md b/pydis_site/apps/content/resources/server-info/roles.md
index 2888f837..edc02066 100644
--- a/pydis_site/apps/content/resources/server-info/roles.md
+++ b/pydis_site/apps/content/resources/server-info/roles.md
@@ -80,8 +80,8 @@ In addition to the informal descriptions below, we've also written down a more f
 ###  DevOps
 **Description:** A role for staff involved with the DevOps toolchain of our core projects.
 
-###  Project Teams
-**Description:** Staff can join teams which work on specific projects in the organisation, such as our code jams, media projects, and more.
+###  Events Team
+**Description:** The events team are staff members who help plan and execute Python Discord events. This can range from the Code Jam, to Pixels, to our survey, specific workshops we want to run, and more.
 
 ###  Helpers
 **Description:** This is the core staff role in our organization: All staff members have the Helpers role.
-- 
cgit v1.2.3
From 40bb274538b80eacc7e2435391af9a87d90ab23d Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Sun, 13 Mar 2022 16:15:49 -0400
Subject: Preliminary structure
---
 .../resources/guides/pydis-guides/contributing.md  | 55 ++++++++++++++++++----
 1 file changed, 46 insertions(+), 9 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
index 4013962c..2f4cce9d 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
@@ -4,7 +4,7 @@ description: A guide to contributing to our open source projects.
 icon: fab fa-github
 ---
 
-Our projects on Python Discord are open source and [available on Github](https://github.com/python-discord).  If you would like to contribute, consider one of the following projects:
+Our projects on Python Discord are open source and [available on GitHub](https://github.com/python-discord).  If you would like to contribute, consider one of the following projects:
 
 
 
@@ -91,14 +91,6 @@ Our projects on Python Discord are open source and [available on Github](https:/
   
 
 
-If you don't understand anything or need clarification, feel free to ask any staff member with the  **@PyDis Core Developers** role in the server. We're always happy to help!
-
-### Useful Resources
-
-[Guidelines](./contributing-guidelines/) - General guidelines you should follow when contributing to our projects.
-[Style Guide](./style-guide/) - Information regarding the code styles you should follow when working on our projects.
-[Review Guide](../code-reviews-primer/) - A guide to get you started on doing code reviews.
-
 ## Contributors Community
 We are very happy to have many members in our community that contribute to [our open source projects](https://github.com/python-discord/).
 Whether it's writing code, reviewing pull requests, or contributing graphics for our events, it’s great to see so many people being motivated to help out.
@@ -114,3 +106,48 @@ As it’s difficult to precisely quantify contributions, we’ve come up with th
 - The member has a positive influence in our contributors subcommunity.
 
 The role will be assigned at the discretion of the Admin Team in consultation with the Core Developers Team.
+
+
+# How do I start contributing?
+  Completing these steps will have you ready to make your first contribution. If you've already been using Git or GitHub feel free to skip those steps, but please make sure to read about the PyDis contributing process and ettiquette. If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides.
+
+
+
+### Fork the repo
+   GitHub is a website based on the Git version control system that stores project files in the cloud. The people working on the project can use GitHub as a central place for sending their changes, getting their teammates' changes, and communicating with each other. Forking the repository that you want to work on will create a copy under your own GitHub account. You'll make your changes to this copy, then later we can bring them back to the PyDis repository.
+
+   [Check out our guide on forking a GitHub repo](./forking-repository/)
+
+### Clone the repo
+  Now that you have your own fork you could make changes to it directly on GitHub, but that's not a convenient way to write code. Instead you can use Git to clone the repo to your local machine, commit changes to it there, then push those changes to GitHub.
+
+  [Check out our guide on forking a GitHub repo](./forking-repository/)
+
+### Set up the project
+  You have the source code on your local computer, but how do you actually run it?
+
+  [Sir Lancebot](./sir-lancebot/)
+
+  [Python Bot](./bot/)
+
+  [Site](./site/)
+
+### Ettiquette
+  [Guidelines](./contributing-guidelines/)
+### Read the style guide
+  [Style Guide](./style-guide/)
+
+### Open a pull request
+
+### The review process
+  [Review Guide](../code-reviews-primer/)
+### Create an issue
+
+
+### Learn the basics of Git
+  Git is a *Version Control System*, software for carefully tracking changes to the files in a project. Git allows the same project to be worked on by people in different places. You can make changes to your local code and then distribute those changes to the other people working on the project.
+
+  [Check out these resources to get started using Git](./working-with-git/)
+
+
+If you don't understand anything or need clarification, feel free to ask any staff member with the  **@PyDis Core Developers** role in the server. We're always happy to help!
-- 
cgit v1.2.3
From 92b1f701b3d1b159b3353db954ce06fe631e56bc Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Sun, 13 Mar 2022 16:27:45 -0400
Subject: Move Contributors role info to the Roles page
---
 .../resources/guides/pydis-guides/contributing.md       | 17 -----------------
 pydis_site/apps/content/resources/server-info/roles.md  |  8 ++++++--
 2 files changed, 6 insertions(+), 19 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
index 2f4cce9d..596afb53 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
@@ -91,23 +91,6 @@ Our projects on Python Discord are open source and [available on GitHub](https:/
   
 
 
-## Contributors Community
-We are very happy to have many members in our community that contribute to [our open source projects](https://github.com/python-discord/).
-Whether it's writing code, reviewing pull requests, or contributing graphics for our events, it’s great to see so many people being motivated to help out.
-As a token of our appreciation, those who have made significant contributions to our projects will receive a special **@Contributors** role on our server that makes them stand out from other members.
-That way, they can also serve as guides to others who are looking to start contributing to our open source projects or open source in general.
-
-#### Guidelines for the @Contributors Role
-
-One question we get a lot is what the requirements for the **@Contributors** role are.
-As it’s difficult to precisely quantify contributions, we’ve come up with the following guidelines for the role:
-
-- The member has made several significant contributions to our projects.
-- The member has a positive influence in our contributors subcommunity.
-
-The role will be assigned at the discretion of the Admin Team in consultation with the Core Developers Team.
-
-
 # How do I start contributing?
   Completing these steps will have you ready to make your first contribution. If you've already been using Git or GitHub feel free to skip those steps, but please make sure to read about the PyDis contributing process and ettiquette. If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides.
 
diff --git a/pydis_site/apps/content/resources/server-info/roles.md b/pydis_site/apps/content/resources/server-info/roles.md
index 716f5b1e..d9e0af15 100644
--- a/pydis_site/apps/content/resources/server-info/roles.md
+++ b/pydis_site/apps/content/resources/server-info/roles.md
@@ -28,8 +28,12 @@ There are multiple requirements listed there for getting the role.
 This includes writing pull requests for open issues, and also for reviewing open pull requests (**we really need reviewers!**)
 
 **How to get it:** Contribute to the projects!
-There is no minimum requirements, but the role is **not** assigned for every single contribution.
-Read more about this in the [Guidelines for the Contributors Role](/pages/contributing/#guidelines-for-the-contributors-role) on the Contributing page.
+It’s difficult to precisely quantify contributions, but we’ve come up with the following guidelines for the role:
+
+- The member has made several significant contributions to our projects.
+- The member has a positive influence in our contributors subcommunity.
+
+The role will be assigned at the discretion of the Admin Team in consultation with the Core Developers Team. Check out our [walkthrough](/pages/contributing/) to get started contributing.
 
 ---
 
-- 
cgit v1.2.3
From 0de454f00fd7de1991a68078eea99a3b8e7004b1 Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Sun, 13 Mar 2022 18:11:16 -0400
Subject: Remove forking and cloning sections in set-up guides
---
 .../guides/pydis-guides/contributing/bot.md        | 27 ----------------------
 .../pydis-guides/contributing/sir-lancebot.md      | 19 ---------------
 .../guides/pydis-guides/contributing/site.md       | 12 ----------
 .../pydis-guides/contributing/working-with-git.md  |  6 +++--
 4 files changed, 4 insertions(+), 60 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
index 2aa10aa3..0f783ef6 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
@@ -7,33 +7,6 @@ toc: 3
 The purpose of this guide is to get you a running local version of [the Python bot](https://github.com/python-discord/bot).
 This page will focus on the quickest steps one can take, with mentions of alternatives afterwards.
 
-### Clone The Repository
-First things first, to run the bot's code and make changes to it, you need a local version of it (on your computer).
-
-
-    
-    
-        
-              
If you don't have Git on your computer already, install it. You can additionally install a Git GUI such as GitKraken, or the GitHub CLI.
-              
To learn more about Git, you can look into our guides, as well as this cheatsheet, Learn Git Branching, and otherwise any guide you can find on the internet. Once you got the basic idea though, the best way to learn Git is to use it.
-              
Creating a copy of a repository under your own account is called a fork. This is where all your changes and commits will be pushed to, and from where your pull requests will originate from.
-              
Learn about forking a project.
-        
-    
-
 
-
-
-You will need to create a fork of [the project](https://github.com/python-discord/bot), and clone the fork.
-Once this is done, you will have completed the first step towards having a running version of the bot.
-
-#### Working on the Repository Directly
-If you are a member of the organisation (a member of [this list](https://github.com/orgs/python-discord/people), or in our particular case, server staff), you can clone the project repository without creating a fork, and work on a feature branch instead.
-
 ---
 
 ### Set Up a Test Server
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md
index e3cd8f0c..6e5a9199 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md
@@ -41,16 +41,6 @@ The requirements for Docker are:
     * This is only a required step for linux. Docker comes bundled with docker-compose on Mac OS and Windows.
 
 ---
-
-# Fork the Project
-You will need your own remote (online) copy of the project repository, known as a *fork*.
-
-- [**Learn how to create a fork of the repository here.**](../forking-repository)
-
-You will do all your work in the fork rather than directly in the main repository.
-
----
-
 # Development Environment
 1. Once you have your fork, you will need to [**clone the repository to your computer**](../cloning-repository).
 2. After cloning, proceed to [**install the project's dependencies**](../installing-project-dependencies). (This is not required if using Docker)
@@ -121,13 +111,4 @@ After installing project dependencies use the poetry command `poetry run task st
 $ poetry run task start
 ```
 
----
-
-# Working with Git
-Now that you have everything setup, it is finally time to make changes to the bot! If you have not yet [read the contributing guidelines](https://github.com/python-discord/sir-lancebot/blob/main/CONTRIBUTING.md), now is a good time. Contributions that do not adhere to the guidelines may be rejected.
-
-Notably, version control of our projects is done using Git and Github. It can be intimidating at first, so feel free to ask for any help in the server.
-
-[**Click here to see the basic Git workflow when contributing to one of our projects.**](../working-with-git/)
-
 Have fun!
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md
index f2c3bd95..7eda027a 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md
@@ -26,18 +26,6 @@ Without Docker:
 - [PostgreSQL](https://www.postgresql.org/download/)
     - Note that if you wish, the webserver can run on the host and still use Docker for PostgreSQL.
 
----
-# Fork the project
-
-You will need access to a copy of the git repository of your own that will allow you to edit the code and push your commits to.
-Creating a copy of a repository under your own account is called a _fork_.
-
-- [Learn how to create a fork of the repository here.](../forking-repository/)
-
-This is where all your changes and commits will be pushed to, and from where your PRs will originate from.
-
-For any Core Developers, since you have write permissions already to the original repository, you can just create a feature branch to push your commits to instead.
-
 ---
 # Development environment
 
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/working-with-git.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/working-with-git.md
index 26c89b56..59c57859 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/working-with-git.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/working-with-git.md
@@ -19,5 +19,7 @@ Below are links to regular workflows for working with Git using PyCharm or the C
 **Resources to learn Git**
 
 * [The Git Book](https://git-scm.com/book)
-* [Corey Schafer's Youtube Tutorials](https://www.youtube.com/watch?v=HVsySz-h9r4&list=PL-osiE80TeTuRUfjRe54Eea17-YfnOOAx)
-* [GitHub Git Resources Portal](https://try.github.io/)
+* [Corey Schafer's YouTube tutorials](https://www.youtube.com/watch?v=HVsySz-h9r4&list=PL-osiE80TeTuRUfjRe54Eea17-YfnOOAx)
+* [GitHub Git resources portal](https://try.github.io/)
+* [Git cheatsheet](https://education.github.com/git-cheat-sheet-education.pdf)
+* [Learn Git branching](https://learngitbranching.js.org)
-- 
cgit v1.2.3
From 35081bc351bde2ee338a855d33d719d689930e1f Mon Sep 17 00:00:00 2001
From: Robin5605 
Date: Mon, 14 Mar 2022 22:49:58 -0500
Subject: Migrate VPS services pin by Python bot to site
---
 .../resources/guides/python-guides/vps-services.md | 25 ++++++++++++++++++++++
 1 file changed, 25 insertions(+)
 create mode 100644 pydis_site/apps/content/resources/guides/python-guides/vps-services.md
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
new file mode 100644
index 00000000..c153e876
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
@@ -0,0 +1,25 @@
+---
+title: VPS Services
+description: On different VPS services
+---
+If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS).
+This is a list of VPS services that are sufficient for running Discord bots.
+
+* https://www.scaleway.com/
+    * Based in Europe.
+* https://www.digitalocean.com/
+    * US-based.
+    * Considered by many to be the gold standard.
+    * Locations available across the world.
+* https://www.ovh.co.uk/
+    * France and Canadian locations available.
+* https://www.time4vps.eu/
+    * Seemingly based in Lithuania.
+* https://www.linode.com/
+    * Cheap VPS.
+* https://www.vultr.com/
+    * US-based, DigitalOcean-like.
+* https://galaxygate.net/
+    * A reliable, affordable, and trusted host.
+
+There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi (any model, except perhaps one of the particularly less powerful ones).
\ No newline at end of file
-- 
cgit v1.2.3
From 831f56f71d76741276d13e20954f46088e754d18 Mon Sep 17 00:00:00 2001
From: Robin5605 
Date: Mon, 14 Mar 2022 23:07:45 -0500
Subject: Fix end of file
---
 pydis_site/apps/content/resources/guides/python-guides/vps-services.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
index c153e876..02feb8e6 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
@@ -22,4 +22,4 @@ This is a list of VPS services that are sufficient for running Discord bots.
 * https://galaxygate.net/
     * A reliable, affordable, and trusted host.
 
-There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi (any model, except perhaps one of the particularly less powerful ones).
\ No newline at end of file
+There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi (any model, except perhaps one of the particularly less powerful ones).
-- 
cgit v1.2.3
From 30b7b4204b7e4b711960c952cccc15f667e2252f Mon Sep 17 00:00:00 2001
From: Chris Lovering 
Date: Sat, 19 Feb 2022 17:57:18 +0000
Subject: Move FilterList imports down so they're sorted
---
 pydis_site/apps/api/models/__init__.py     |  2 +-
 pydis_site/apps/api/models/bot/__init__.py |  2 +-
 pydis_site/apps/api/urls.py                | 16 ++++++++--------
 pydis_site/apps/api/viewsets/__init__.py   |  2 +-
 4 files changed, 11 insertions(+), 11 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index 4f616986..e83473c9 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -1,9 +1,9 @@
 # flake8: noqa
 from .bot import (
-    FilterList,
     BotSetting,
     DocumentationLink,
     DeletedMessage,
+    FilterList,
     Infraction,
     Message,
     MessageDeletionContext,
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index ec0e701c..64676fdb 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -1,8 +1,8 @@
 # flake8: noqa
-from .filter_list import FilterList
 from .bot_setting import BotSetting
 from .deleted_message import DeletedMessage
 from .documentation_link import DocumentationLink
+from .filter_list import FilterList
 from .infraction import Infraction
 from .message import Message
 from .aoc_completionist_block import AocCompletionistBlock
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index 7c55fc92..6b881fac 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -21,8 +21,12 @@ from .viewsets import (
 # https://www.django-rest-framework.org/api-guide/routers/#defaultrouter
 bot_router = DefaultRouter(trailing_slash=False)
 bot_router.register(
-    'filter-lists',
-    FilterListViewSet
+    "aoc-account-links",
+    AocAccountLinkViewSet
+)
+bot_router.register(
+    "aoc-completionist-blocks",
+    AocCompletionistBlockViewSet
 )
 bot_router.register(
     'bot-settings',
@@ -37,12 +41,8 @@ bot_router.register(
     DocumentationLinkViewSet
 )
 bot_router.register(
-    "aoc-account-links",
-    AocAccountLinkViewSet
-)
-bot_router.register(
-    "aoc-completionist-blocks",
-    AocCompletionistBlockViewSet
+    'filter-lists',
+    FilterListViewSet
 )
 bot_router.register(
     'infractions',
diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
index 5fc1d64f..a62a9c01 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -1,9 +1,9 @@
 # flake8: noqa
 from .bot import (
-    FilterListViewSet,
     BotSettingViewSet,
     DeletedMessageViewSet,
     DocumentationLinkViewSet,
+    FilterListViewSet,
     InfractionViewSet,
     NominationViewSet,
     OffensiveMessageViewSet,
-- 
cgit v1.2.3
From 0aed5f7913e7ce268ddb56127f84a5386ede5739 Mon Sep 17 00:00:00 2001
From: Chris Lovering 
Date: Sat, 19 Feb 2022 17:59:26 +0000
Subject: Add support for BumpedThreads to be stored in site
Following our move to use Redis as just a cache, this PR allows the site to store a list of threads that need to be bumped.
The bot will interact with this within the ThreadBumper cog.
---
 .../apps/api/migrations/0081_bumpedthread.py       | 22 ++++++++
 pydis_site/apps/api/models/__init__.py             |  1 +
 pydis_site/apps/api/models/bot/__init__.py         |  1 +
 pydis_site/apps/api/models/bot/bumped_thread.py    | 22 ++++++++
 pydis_site/apps/api/serializers.py                 | 11 ++++
 pydis_site/apps/api/urls.py                        |  5 ++
 pydis_site/apps/api/viewsets/__init__.py           |  1 +
 pydis_site/apps/api/viewsets/bot/__init__.py       |  1 +
 pydis_site/apps/api/viewsets/bot/bumped_thread.py  | 65 ++++++++++++++++++++++
 9 files changed, 129 insertions(+)
 create mode 100644 pydis_site/apps/api/migrations/0081_bumpedthread.py
 create mode 100644 pydis_site/apps/api/models/bot/bumped_thread.py
 create mode 100644 pydis_site/apps/api/viewsets/bot/bumped_thread.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0081_bumpedthread.py b/pydis_site/apps/api/migrations/0081_bumpedthread.py
new file mode 100644
index 00000000..03e66cc1
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0081_bumpedthread.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.1.14 on 2022-02-19 16:26
+
+import django.core.validators
+from django.db import migrations, models
+import pydis_site.apps.api.models.mixins
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0080_add_aoc_tables'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='BumpedThread',
+            fields=[
+                ('thread_id', models.BigIntegerField(help_text='The thread ID that should be bumped.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Thread IDs cannot be negative.')], verbose_name='Thread ID')),
+            ],
+            bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
+        ),
+    ]
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index e83473c9..a197e988 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -1,6 +1,7 @@
 # flake8: noqa
 from .bot import (
     BotSetting,
+    BumpedThread,
     DocumentationLink,
     DeletedMessage,
     FilterList,
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index 64676fdb..013bb85e 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -1,5 +1,6 @@
 # flake8: noqa
 from .bot_setting import BotSetting
+from .bumped_thread import BumpedThread
 from .deleted_message import DeletedMessage
 from .documentation_link import DocumentationLink
 from .filter_list import FilterList
diff --git a/pydis_site/apps/api/models/bot/bumped_thread.py b/pydis_site/apps/api/models/bot/bumped_thread.py
new file mode 100644
index 00000000..cdf9a950
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/bumped_thread.py
@@ -0,0 +1,22 @@
+from django.core.validators import MinValueValidator
+from django.db import models
+
+from pydis_site.apps.api.models.mixins import ModelReprMixin
+
+
+class BumpedThread(ModelReprMixin, models.Model):
+    """A list of thread IDs to be bumped."""
+
+    thread_id = models.BigIntegerField(
+        primary_key=True,
+        help_text=(
+            "The thread ID that should be bumped."
+        ),
+        validators=(
+            MinValueValidator(
+                limit_value=0,
+                message="Thread IDs cannot be negative."
+            ),
+        ),
+        verbose_name="Thread ID",
+    )
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index c97f7dba..b9e06081 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -16,6 +16,7 @@ from .models import (
     AocAccountLink,
     AocCompletionistBlock,
     BotSetting,
+    BumpedThread,
     DeletedMessage,
     DocumentationLink,
     FilterList,
@@ -41,6 +42,16 @@ class BotSettingSerializer(ModelSerializer):
         fields = ('name', 'data')
 
 
+class BumpedThreadSerializer(ModelSerializer):
+    """A class providing (de-)serialization of `BumpedThread` instances."""
+
+    class Meta:
+        """Metadata defined for the Django REST Framework."""
+
+        model = BumpedThread
+        fields = ('thread_id',)
+
+
 class DeletedMessageSerializer(ModelSerializer):
     """
     A class providing (de-)serialization of `DeletedMessage` instances.
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index 6b881fac..1e564b29 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -6,6 +6,7 @@ from .viewsets import (
     AocAccountLinkViewSet,
     AocCompletionistBlockViewSet,
     BotSettingViewSet,
+    BumpedThreadViewSet,
     DeletedMessageViewSet,
     DocumentationLinkViewSet,
     FilterListViewSet,
@@ -32,6 +33,10 @@ bot_router.register(
     'bot-settings',
     BotSettingViewSet
 )
+bot_router.register(
+    'bumped-threads',
+    BumpedThreadViewSet
+)
 bot_router.register(
     'deleted-messages',
     DeletedMessageViewSet
diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
index a62a9c01..ec52416a 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -1,6 +1,7 @@
 # flake8: noqa
 from .bot import (
     BotSettingViewSet,
+    BumpedThreadViewSet,
     DeletedMessageViewSet,
     DocumentationLinkViewSet,
     FilterListViewSet,
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
index f1d84729..262aa59f 100644
--- a/pydis_site/apps/api/viewsets/bot/__init__.py
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -1,6 +1,7 @@
 # flake8: noqa
 from .filter_list import FilterListViewSet
 from .bot_setting import BotSettingViewSet
+from .bumped_thread import BumpedThreadViewSet
 from .deleted_message import DeletedMessageViewSet
 from .documentation_link import DocumentationLinkViewSet
 from .infraction import InfractionViewSet
diff --git a/pydis_site/apps/api/viewsets/bot/bumped_thread.py b/pydis_site/apps/api/viewsets/bot/bumped_thread.py
new file mode 100644
index 00000000..6594ac6e
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/bumped_thread.py
@@ -0,0 +1,65 @@
+from rest_framework.mixins import (
+    CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import BumpedThread
+from pydis_site.apps.api.serializers import BumpedThreadSerializer
+
+
+class BumpedThreadViewSet(
+    GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin
+):
+    """
+    View providing CRUD (Minus the U) operations on threads to be bumped.
+
+    ## Routes
+    ### GET /bot/bumped-threads
+    Returns all BumpedThread items in the database.
+
+    #### Response format
+    >>> [
+    ...     {
+    ...         'thread_id': "941705627405811793",
+    ...     },
+    ...     ...
+    ... ]
+
+    #### Status codes
+    - 200: returned on success
+    - 401: returned if unauthenticated
+
+    ### GET /bot/bumped-threads/
+    Returns a specific BumpedThread item from the database.
+
+    #### Response format
+    >>> {
+    ...     'thread_id': "941705627405811793",
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 404: returned if a BumpedThread with the given thread_id was not found.
+
+    ### POST /bot/bumped-threads
+    Adds a single BumpedThread item to the database.
+
+    #### Request body
+    >>> {
+    ...    'thread_id': int,
+    ... }
+
+    #### Status codes
+    - 201: returned on success
+    - 400: if one of the given fields is invalid
+
+    ### DELETE /bot/bumped-threads/
+    Deletes the BumpedThread item with the given `thread_id`.
+
+    #### Status codes
+    - 204: returned on success
+    - 404: if a BumpedThread with the given `thread_id` does not exist
+    """
+
+    serializer_class = BumpedThreadSerializer
+    queryset = BumpedThread.objects.all()
-- 
cgit v1.2.3
From 3e9557056c06a39c077b76d718eb35b99a365711 Mon Sep 17 00:00:00 2001
From: Chris Lovering 
Date: Wed, 16 Mar 2022 21:46:46 +0000
Subject: Only return list of ints when retrieving all BumpedThreads
---
 pydis_site/apps/api/serializers.py                | 17 +++++++++++++++++
 pydis_site/apps/api/viewsets/bot/bumped_thread.py |  7 +------
 2 files changed, 18 insertions(+), 6 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index b9e06081..dfdda915 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -42,12 +42,29 @@ class BotSettingSerializer(ModelSerializer):
         fields = ('name', 'data')
 
 
+class ListBumpedThreadSerializer(ListSerializer):
+    """Custom ListSerializer to override to_representation() when list views are triggered."""
+
+    def to_representation(self, objects: list[BumpedThread]) -> int:
+        """
+        Used by the `ListModelMixin` to return just the list of bumped thread ids.
+
+        We want to only return the thread_id attribute, hence it is unnecessary
+        to create a nested dictionary.
+
+        Additionally, this allows bumped thread routes to simply return an
+        array of thread_id ints instead of objects, saving on bandwidth.
+        """
+        return [obj.thread_id for obj in objects]
+
+
 class BumpedThreadSerializer(ModelSerializer):
     """A class providing (de-)serialization of `BumpedThread` instances."""
 
     class Meta:
         """Metadata defined for the Django REST Framework."""
 
+        list_serializer_class = ListBumpedThreadSerializer
         model = BumpedThread
         fields = ('thread_id',)
 
diff --git a/pydis_site/apps/api/viewsets/bot/bumped_thread.py b/pydis_site/apps/api/viewsets/bot/bumped_thread.py
index 6594ac6e..0972379b 100644
--- a/pydis_site/apps/api/viewsets/bot/bumped_thread.py
+++ b/pydis_site/apps/api/viewsets/bot/bumped_thread.py
@@ -18,12 +18,7 @@ class BumpedThreadViewSet(
     Returns all BumpedThread items in the database.
 
     #### Response format
-    >>> [
-    ...     {
-    ...         'thread_id': "941705627405811793",
-    ...     },
-    ...     ...
-    ... ]
+    >>> list[int]
 
     #### Status codes
     - 200: returned on success
-- 
cgit v1.2.3
From a0180619e77d884aeedb1a89748c0115c8ec8c56 Mon Sep 17 00:00:00 2001
From: Chris Lovering 
Date: Wed, 16 Mar 2022 21:47:57 +0000
Subject: Don't return the BumpedThread object when retrieving single
We only need to check for existence, so sending the full object isn't needed.
---
 pydis_site/apps/api/viewsets/bot/bumped_thread.py | 24 ++++++++++++++---------
 1 file changed, 15 insertions(+), 9 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/viewsets/bot/bumped_thread.py b/pydis_site/apps/api/viewsets/bot/bumped_thread.py
index 0972379b..9d77bb6b 100644
--- a/pydis_site/apps/api/viewsets/bot/bumped_thread.py
+++ b/pydis_site/apps/api/viewsets/bot/bumped_thread.py
@@ -1,6 +1,8 @@
 from rest_framework.mixins import (
-    CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
+    CreateModelMixin, DestroyModelMixin, ListModelMixin
 )
+from rest_framework.request import Request
+from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet
 
 from pydis_site.apps.api.models.bot import BumpedThread
@@ -8,7 +10,7 @@ from pydis_site.apps.api.serializers import BumpedThreadSerializer
 
 
 class BumpedThreadViewSet(
-    GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin
+    GenericViewSet, CreateModelMixin, DestroyModelMixin, ListModelMixin
 ):
     """
     View providing CRUD (Minus the U) operations on threads to be bumped.
@@ -25,15 +27,10 @@ class BumpedThreadViewSet(
     - 401: returned if unauthenticated
 
     ### GET /bot/bumped-threads/
-    Returns a specific BumpedThread item from the database.
-
-    #### Response format
-    >>> {
-    ...     'thread_id': "941705627405811793",
-    ... }
+    Returns whether a specific BumpedThread exists in the database.
 
     #### Status codes
-    - 200: returned on success
+    - 204: returned on success
     - 404: returned if a BumpedThread with the given thread_id was not found.
 
     ### POST /bot/bumped-threads
@@ -58,3 +55,12 @@ class BumpedThreadViewSet(
 
     serializer_class = BumpedThreadSerializer
     queryset = BumpedThread.objects.all()
+
+    def retrieve(self, request: Request, *args, **kwargs) -> Response:
+        """
+        DRF method for checking if the given BumpedThread exists.
+
+        Called by the Django Rest Framework in response to the corresponding HTTP request.
+        """
+        self.get_object()
+        return Response(status=204)
-- 
cgit v1.2.3
From cb52e60a631041a08edb213f41cca8befba4bf7e Mon Sep 17 00:00:00 2001
From: Chris Lovering 
Date: Wed, 16 Mar 2022 23:17:46 +0000
Subject: Add tests for custom BumpedThread impl
---
 pydis_site/apps/api/tests/test_bumped_threads.py | 63 ++++++++++++++++++++++++
 1 file changed, 63 insertions(+)
 create mode 100644 pydis_site/apps/api/tests/test_bumped_threads.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/tests/test_bumped_threads.py b/pydis_site/apps/api/tests/test_bumped_threads.py
new file mode 100644
index 00000000..316e3f0b
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_bumped_threads.py
@@ -0,0 +1,63 @@
+from django.urls import reverse
+
+from .base import AuthenticatedAPITestCase
+from ..models import BumpedThread
+
+
+class UnauthedBumpedThreadAPITests(AuthenticatedAPITestCase):
+    def setUp(self):
+        super().setUp()
+        self.client.force_authenticate(user=None)
+
+    def test_detail_lookup_returns_401(self):
+        url = reverse('api:bot:bumpedthread-detail', args=(1,))
+        response = self.client.get(url)
+
+        self.assertEqual(response.status_code, 401)
+
+    def test_list_returns_401(self):
+        url = reverse('api:bot:bumpedthread-list')
+        response = self.client.get(url)
+
+        self.assertEqual(response.status_code, 401)
+
+    def test_create_returns_401(self):
+        url = reverse('api:bot:bumpedthread-list')
+        response = self.client.post(url, {"thread_id": 3})
+
+        self.assertEqual(response.status_code, 401)
+
+    def test_delete_returns_401(self):
+        url = reverse('api:bot:bumpedthread-detail', args=(1,))
+        response = self.client.delete(url)
+
+        self.assertEqual(response.status_code, 401)
+
+
+class BumpedThreadAPITests(AuthenticatedAPITestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.thread1 = BumpedThread.objects.create(
+            thread_id=1234,
+        )
+
+    def test_returns_bumped_threads_as_flat_list(self):
+        url = reverse('api:bot:bumpedthread-list')
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), [1234])
+
+    def test_returns_204_for_existing_data(self):
+        url = reverse('api:bot:bumpedthread-detail', args=(1234,))
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 204)
+        self.assertEqual(response.content, b"")
+
+    def test_returns_404_for_non_existing_data(self):
+        url = reverse('api:bot:bumpedthread-detail', args=(42,))
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 404)
+        self.assertEqual(response.json(), {"detail": "Not found."})
-- 
cgit v1.2.3
From 429d98a5349b55e63f93e6192d8b2b35262dc60b Mon Sep 17 00:00:00 2001
From: ChrisJL 
Date: Fri, 18 Mar 2022 15:11:34 +0000
Subject: fixup: don't use "We" in docstring
Co-authored-by: Mark <1515135+MarkKoz@users.noreply.github.com>
---
 pydis_site/apps/api/serializers.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index dfdda915..e53ccffa 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -49,8 +49,7 @@ class ListBumpedThreadSerializer(ListSerializer):
         """
         Used by the `ListModelMixin` to return just the list of bumped thread ids.
 
-        We want to only return the thread_id attribute, hence it is unnecessary
-        to create a nested dictionary.
+        Only the thread_id field is useful, hence it is unnecessary to create a nested dictionary.
 
         Additionally, this allows bumped thread routes to simply return an
         array of thread_id ints instead of objects, saving on bandwidth.
-- 
cgit v1.2.3
From 36b43f94cb1f8c622914048696efec8ccdeb608f Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Sat, 19 Mar 2022 23:42:45 -0400
Subject: Add guide for pull requests and reviews
This covers the GitHub UI for opening a pull request,
 getting it reviewed, and draft PRs.
---
 .../pydis-guides/contributing/pull-requests.md     |  41 +++++++++++++++++++++
 .../images/content/contributing/pull_request.png   | Bin 0 -> 10190 bytes
 2 files changed, 41 insertions(+)
 create mode 100644 pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md
 create mode 100644 pydis_site/static/images/content/contributing/pull_request.png
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md
new file mode 100644
index 00000000..f7dee491
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md
@@ -0,0 +1,41 @@
+---
+title: Pull Requests
+description: A guide for opening pull requests.
+---
+
+As stated in our [Contributing Guidelines](../contributing-guidelines/), do not open a pull request if you aren't assigned to an approved issue. You can check out our [Issues Guide](../issues/) for help with opening an issue or getting assigned to an existing one.
+{: .notification .is-warning }
+
+
+Before opening a pull request you should have:
+
+1. Committed your changes to your local repository
+2. [Linted](../contributing-guidelines/#linting-and-pre-commit) your code
+3. Tested your changes
+4. Pushed the branch to your fork of the project on GitHub
+
+## Opening a Pull Request
+
+Navigate to your fork on GitHub and make sure you're on the branch with your changes. Click on `Contribute` and then `Open pull request`.
+
+
+
+In the page that it opened, write an overview of the changes you made and why. This should explain how you resolved the issue that spawned this PR and highlight any differences from the proposed implementation. You should also [link the issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
+
+At this stage you can also request reviews from individual contributors. If someone showed interest in the issue or has specific knowledge about it, they may be a good reviewer. It isn't necessary to request your reviewers; someone will review your PR either way.
+
+## The Review Process
+
+Before your changes are merged, your PR needs to be reviewed by other contributors. They will read the issue and your description of your PR, look at your code, test it, and then leave comments on the PR if they find any problems, possibly with suggested changes. Sometimes this can feel intrusive or insulting, but remember that the reviewers are there to help you make your code better.
+
+#### If the PR is already open, how do I make changes to it?
+
+A pull request is between a source branch and a target branch. Updating the source branch with new commits will automatically update the PR to include those commits; they'll even show up in the comment thread of the PR. Sometimes for small changes the reviewer will even write the suggested code themself, in which case you can simply accept them with the click of a button.
+
+If you truly disagree with a reviewer's suggestion, leave a reply in the thread explaining why or proposing an alternative change. Also feel free to ask questions if you want clarification about suggested changes or just want to discuss them further.
+
+## Draft Pull Requests
+
+GitHub [provides a PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a draft when opening it. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review. This is helpful when you want people to see the changes you're making before you're ready for the final pull request.
+
+This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title.
diff --git a/pydis_site/static/images/content/contributing/pull_request.png b/pydis_site/static/images/content/contributing/pull_request.png
new file mode 100644
index 00000000..87b7ffbe
Binary files /dev/null and b/pydis_site/static/images/content/contributing/pull_request.png differ
-- 
cgit v1.2.3
From d198b3e02236e65bc2d8fceb9d7d8776dc306b87 Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Sun, 20 Mar 2022 14:56:05 -0400
Subject: Split up Supplemental Info and touch up Contrib Guidelines
Created new pages:
* Linting
* Logging
* Writing Good Commit Messages
Moved Draft PR section to new pull requests guide
Moved type hinting section to style guide
---
 .../contributing/contributing-guidelines.md        | 25 +++---
 .../contributing-guidelines/commit-messages.md     | 15 ++++
 .../supplemental-information.md                    | 99 ----------------------
 .../guides/pydis-guides/contributing/linting.md    | 14 +++
 .../guides/pydis-guides/contributing/logging.md    | 31 +++++++
 .../pydis-guides/contributing/pull-requests.md     |  2 +-
 .../pydis-guides/contributing/style-guide.md       | 24 +++---
 7 files changed, 82 insertions(+), 128 deletions(-)
 create mode 100644 pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/commit-messages.md
 delete mode 100644 pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/supplemental-information.md
 create mode 100644 pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md
 create mode 100644 pydis_site/apps/content/resources/guides/pydis-guides/contributing/logging.md
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md
index de1777f2..f13b05be 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md
@@ -4,22 +4,15 @@ description: Guidelines to adhere to when contributing to our projects.
 ---
 
 Thank you for your interest in our projects!
+This page contains the golden rules to follow when contributing. If you have questions about how to get started contributing, check out our [in-depth walkthrough](../../contributing/).
 
-If you are interested in contributing, **this page contains the golden rules to follow when contributing.**
-Supplemental information [can be found here](./supplemental-information/).
-Do note that failing to comply with our guidelines may lead to a rejection of the contribution.
-
-If you are confused by any of these rules, feel free to ask us in the `#dev-contrib` channel in our [Discord server.](https://discord.gg/python)
-
-# The Golden Rules of Contributing
-
-1. **Lint before you push.** We have simple but strict style rules that are enforced through linting.
-You must always lint your code before committing or pushing.
-[Using tools](./supplemental-information/#linting-and-pre-commit) such as `flake8` and `pre-commit` can make this easier.
-Make sure to follow our [style guide](../style-guide/) when contributing.
+1. **Lint before you push.**
+We have simple but strict style rules that are enforced through linting.
+[Set up a pre-commit hook](../linting/) to lint your code when you commit it.
+Not all of the style rules are enforced by linting, so make sure to read the [style guide](../style-guide/) as well.
 2. **Make great commits.**
 Great commits should be atomic, with a commit message explaining what and why.
-More on that can be found in [this section](./supplemental-information/#writing-good-commit-messages).
+Check out [Writing Good Commit Messages](/commit-messages) for details.
 3. **Do not open a pull request if you aren't assigned to the issue.**
 If someone is already working on it, consider offering to collaborate with that person.
 4. **Use assets licensed for public use.**
@@ -28,4 +21,8 @@ Whenever the assets are images, audio or even code, they must have a license com
 We aim to foster a welcoming and friendly environment on our open source projects.
 We take violations of our Code of Conduct very seriously, and may respond with moderator action.
 
-Welcome to our projects!
+
+
+Failing to comply with our guidelines may lead to a rejection of the contribution.
+If you have questions about any of the rules, feel free to ask us in the `#dev-contrib` channel in our [Discord server](https://discord.gg/python).
+{: .notification .is-warning }
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/commit-messages.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/commit-messages.md
new file mode 100644
index 00000000..ba476b65
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/commit-messages.md
@@ -0,0 +1,15 @@
+---
+title: Writing Good Commit Messages
+description: Information about logging in our projects.
+---
+
+A well-structured git log is key to a project's maintainability; it provides insight into when and *why* things were done for future maintainers of the project.
+
+Commits should be as narrow in scope as possible.
+Commits that span hundreds of lines across multiple unrelated functions and/or files are very hard for maintainers to follow.
+After about a week they'll probably be hard for you to follow, too.
+
+Please also avoid making minor commits for fixing typos or linting errors.
+[Don’t forget to lint before you push!](https://soundcloud.com/lemonsaurusrex/lint-before-you-push)
+
+A more in-depth guide to writing great commit messages can be found in Chris Beam's [How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/).
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/supplemental-information.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/supplemental-information.md
deleted file mode 100644
index e64e4fc6..00000000
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/supplemental-information.md
+++ /dev/null
@@ -1,99 +0,0 @@
----
-title: Supplemental Information
-description: Additional information related to our contributing guidelines.
----
-
-This page contains additional information concerning a specific part of our development pipeline.
-
-## Writing Good Commit Messages
-
-A well-structured git log is key to a project's maintainability; it provides insight into when and *why* things were done for future maintainers of the project.
-
-Commits should be as narrow in scope as possible.
-Commits that span hundreds of lines across multiple unrelated functions and/or files are very hard for maintainers to follow.
-After about a week they'll probably be hard for you to follow, too.
-
-Please also avoid making minor commits for fixing typos or linting errors.
-*[Don’t forget to lint before you push!](https://soundcloud.com/lemonsaurusrex/lint-before-you-push)*
-
-A more in-depth guide to writing great commit messages can be found in Chris Beam's *[How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/).*
-
-## Code Style
-
-All of our projects have a certain project-wide style that contributions should attempt to maintain consistency with.
-During PR review, it's not unusual for style adjustments to be requested.
-
-[This page](../../style-guide/) will reference the differences between our projects and what is recommended by [PEP 8.](https://www.python.org/dev/peps/pep-0008/)
-
-## Linting and Pre-commit
-
-On most of our projects, we use `flake8` and `pre-commit` to ensure that the code style is consistent across the code base.
-
-Running `flake8` will warn you about any potential style errors in your contribution.
-You must always check it **before pushing**.
-Your commit will be rejected by the build server if it fails to lint.
-
-**Some style rules are not enforced by flake8. Make sure to read the [style guide](../../style-guide/).**
-
-`pre-commit` is a powerful tool that helps you automatically lint before you commit.
-If the linter complains, the commit is aborted so that you can fix the linting errors before committing again.
-That way, you never commit the problematic code in the first place!
-
-Please refer to the project-specific documentation to see how to setup and run those tools.
-In most cases, you can install pre-commit using `poetry run task precommit`, and lint using `poetry run task lint`.
-
-## Type Hinting
-
-[PEP 484](https://www.python.org/dev/peps/pep-0484/) formally specifies type hints for Python functions, added to the Python Standard Library in version 3.5.
-Type hints are recognized by most modern code editing tools and provide useful insight into both the input and output types of a function, preventing the user from having to go through the codebase to determine these types.
-
-For example:
-
-```python
-import typing
-
-def foo(input_1: int, input_2: typing.Dict[str, str]) -> bool:
-    ...
-```
-
-This tells us that `foo` accepts an `int` and a `dict`, with `str` keys and values, and returns a `bool`.
-
-If the project is running Python 3.9 or above, you can use `dict` instead of `typing.Dict`.
-See [PEP 585](https://www.python.org/dev/peps/pep-0585/) for more information.
-
-All function declarations should be type hinted in code contributed to the PyDis organization.
-
-## Logging
-
-Instead of using `print` statements for logging, we use the built-in [`logging`](https://docs.python.org/3/library/logging.html) module.
-Here is an example usage:
-
-```python
-import logging
-
-log = logging.getLogger(__name__) # Get a logger bound to the module name.
-# This line is usually placed under the import statements at the top of the file.
-
-log.trace("This is a trace log.")
-log.warning("BEEP! This is a warning.")
-log.critical("It is about to go down!")
-```
-
-Print statements should be avoided when possible.
-Our projects currently defines logging levels as follows, from lowest to highest severity:
-
-- **TRACE:** These events should be used to provide a *verbose* trace of every step of a complex process. This is essentially the `logging` equivalent of sprinkling `print` statements throughout the code.
-- **Note:** This is a PyDis-implemented logging level. It may not be available on every project.
-- **DEBUG:** These events should add context to what's happening in a development setup to make it easier to follow what's going while workig on a project. This is in the same vein as **TRACE** logging but at a much lower level of verbosity.
-- **INFO:** These events are normal and don't need direct attention but are worth keeping track of in production, like checking which cogs were loaded during a start-up.
-- **WARNING:** These events are out of the ordinary and should be fixed, but can cause a failure.
-- **ERROR:** These events can cause a failure in a specific part of the application and require urgent attention.
-- **CRITICAL:** These events can cause the whole application to fail and require immediate intervention.
-
-Any logging above the **INFO** level will trigger a [Sentry](https://sentry.io) issue and alert the Core Developer team.
-
-## Draft Pull Requests
-
-Github [provides a PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a Draft when opening it. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review.
-
-This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title.
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md
new file mode 100644
index 00000000..48f1cafc
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md
@@ -0,0 +1,14 @@
+---
+title: Linting
+description: A guide for linting and setting up pre-commit.
+---
+
+Your commit will be rejected by the build server if it fails to lint.
+On most of our projects, we use `flake8` and `pre-commit` to ensure that the code style is consistent across the code base.
+
+`pre-commit` is a powerful tool that helps you automatically lint before you commit.
+If the linter complains, the commit is aborted so that you can fix the linting errors before committing again.
+That way, you never commit the problematic code in the first place!
+
+Please refer to the project-specific documentation to see how to setup and run those tools.
+In most cases, you can install pre-commit using `poetry run task precommit`, and lint using `poetry run task lint`.
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/logging.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/logging.md
new file mode 100644
index 00000000..1291a7a4
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/logging.md
@@ -0,0 +1,31 @@
+---
+title: Logging
+description: Information about logging in our projects.
+---
+
+Instead of using `print` statements for logging, we use the built-in [`logging`](https://docs.python.org/3/library/logging.html) module.
+Here is an example usage:
+
+```python
+import logging
+
+log = logging.getLogger(__name__) # Get a logger bound to the module name.
+# This line is usually placed under the import statements at the top of the file.
+
+log.trace("This is a trace log.")
+log.warning("BEEP! This is a warning.")
+log.critical("It is about to go down!")
+```
+
+Print statements should be avoided when possible.
+Our projects currently defines logging levels as follows, from lowest to highest severity:
+
+- **TRACE:** These events should be used to provide a *verbose* trace of every step of a complex process. This is essentially the `logging` equivalent of sprinkling `print` statements throughout the code.
+- **Note:** This is a PyDis-implemented logging level. It may not be available on every project.
+- **DEBUG:** These events should add context to what's happening in a development setup to make it easier to follow what's going while workig on a project. This is in the same vein as **TRACE** logging but at a much lower level of verbosity.
+- **INFO:** These events are normal and don't need direct attention but are worth keeping track of in production, like checking which cogs were loaded during a start-up.
+- **WARNING:** These events are out of the ordinary and should be fixed, but can cause a failure.
+- **ERROR:** These events can cause a failure in a specific part of the application and require urgent attention.
+- **CRITICAL:** These events can cause the whole application to fail and require immediate intervention.
+
+Any logging above the **INFO** level will trigger a [Sentry](https://sentry.io) issue and alert the Core Developer team.
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md
index f7dee491..a9b6385e 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md
@@ -10,7 +10,7 @@ As stated in our [Contributing Guidelines](../contributing-guidelines/), do not
 Before opening a pull request you should have:
 
 1. Committed your changes to your local repository
-2. [Linted](../contributing-guidelines/#linting-and-pre-commit) your code
+2. [Linted](../linting/) your code
 3. Tested your changes
 4. Pushed the branch to your fork of the project on GitHub
 
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/style-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/style-guide.md
index f9962990..4dba45c8 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/style-guide.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/style-guide.md
@@ -191,21 +191,17 @@ Present tense defines that the work being done is now, in the present, rather th
 **Use:** "Build an information embed."
 **Don't use:** "Built an information embed." or "Will build an information embed."
 
-# Type Annotations
-Functions are required to have type annotations as per the style defined in [PEP 484](https://www.python.org/dev/peps/pep-0484/).
+# Type Hinting
+Functions are required to have type annotations as per the style defined in [PEP 484](https://www.python.org/dev/peps/pep-0484/). Type hints are recognized by most modern code editing tools and provide useful insight into both the input and output types of a function, preventing the user from having to go through the codebase to determine these types.
 
-A function without annotations might look like:
-```py
-def divide(a, b):
-    """Divide the two given arguments."""
-    return a / b
-```
-
-With annotations, the arguments and the function are annotated with their respective types:
-```py
-def divide(a: int, b: int) -> float:
-    """Divide the two given arguments."""
-    return a / b
+A function with type hints looks like:
+```python
+def foo(input_1: int, input_2: dict[str, int]) -> bool:
+    ...
 ```
+This tells us that `foo` accepts an `int` and a `dict`, with `str` keys and `int` values, and returns a `bool`.
 
 In previous examples, we have purposely omitted annotations to keep focus on the specific points they represent.
+
+> **Note:** if the project is running Python 3.8 or below you have to use `typing.Dict` instead of `dict`, but our three main projects are all >=3.9.
+> See [PEP 585](https://www.python.org/dev/peps/pep-0585/) for more information.
-- 
cgit v1.2.3
From 79694efe7bb13a9d6791844e7a836b7f4350308d Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Sun, 20 Mar 2022 17:08:08 -0400
Subject: Write walkthrough
---
 .../resources/guides/pydis-guides/contributing.md  | 60 +++++++++++++---------
 1 file changed, 36 insertions(+), 24 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
index 596afb53..759ed1dc 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
@@ -92,45 +92,57 @@ Our projects on Python Discord are open source and [available on GitHub](https:/
 
 
 # How do I start contributing?
-  Completing these steps will have you ready to make your first contribution. If you've already been using Git or GitHub feel free to skip those steps, but please make sure to read about the PyDis contributing process and ettiquette. If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides.
+  Unsure of what contributing to open source projects involves? Have questions about how to use GitHub? Just need to know about our contribution etiquette? Completing these steps will have you ready to make your first contribution.
 
+  Feel free to skip any steps you're already familiar with, but please make sure not to miss the  [Contributing Guidelines](#5-read-our-contributing-guidelines).
 
+  If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides.
 
-### Fork the repo
-   GitHub is a website based on the Git version control system that stores project files in the cloud. The people working on the project can use GitHub as a central place for sending their changes, getting their teammates' changes, and communicating with each other. Forking the repository that you want to work on will create a copy under your own GitHub account. You'll make your changes to this copy, then later we can bring them back to the PyDis repository.
+### 1. Learn the basics of Git
+  Git is a _Version Control System_, software for carefully tracking changes to the files in a project. Git allows the same project to be worked on by people in different places. You can make changes to your local code and then distribute those changes to the other people working on the project.
 
-   [Check out our guide on forking a GitHub repo](./forking-repository/)
+  Check out these [**resources to get started using Git**](./working-with-git/).
 
-### Clone the repo
-  Now that you have your own fork you could make changes to it directly on GitHub, but that's not a convenient way to write code. Instead you can use Git to clone the repo to your local machine, commit changes to it there, then push those changes to GitHub.
+### 2. Fork the repo
+  GitHub is a website based on the Git version control system that stores project files in the cloud. The people working on the project can use GitHub as a central place for sending their changes, getting their teammates' changes, and communicating with each other. Forking the repository that you want to work on will create a copy under your own GitHub account. You'll make your changes to this copy, then later we can bring them back to the PyDis repository.
 
-  [Check out our guide on forking a GitHub repo](./forking-repository/)
+  Check out our [**guide on forking a GitHub repo**](./forking-repository/).
 
-### Set up the project
-  You have the source code on your local computer, but how do you actually run it?
+### 3. Clone the repo
+  Now that you have your own fork you need to be able to make changes to the code. You can clone the repo to your local machine, commit changes to it there, then push those changes to GitHub.
 
-  [Sir Lancebot](./sir-lancebot/)
+  Check out our [**guide on cloning a GitHub repo**](./cloning-repository/).
 
-  [Python Bot](./bot/)
+### 4. Set up the project
+  You have the source code on your local computer, now how do you actually run it? We have detailed guides on setting up the environment for each of our projects:
 
-  [Site](./site/)
+  * [**Sir Lancebot**](./sir-lancebot/)
 
-### Ettiquette
-  [Guidelines](./contributing-guidelines/)
-### Read the style guide
-  [Style Guide](./style-guide/)
+  * [**Python Bot**](./bot/)
 
-### Open a pull request
+  * [**Site**](./site/)
 
-### The review process
-  [Review Guide](../code-reviews-primer/)
-### Create an issue
+### 5. Read our Contributing Guidelines
+  We have a few short rules that all contributors must follow. Make sure you read and follow them while working on our projects.
 
+  [**Contributing Guidelines**](./contributing-guidelines/).
 
-### Learn the basics of Git
-  Git is a *Version Control System*, software for carefully tracking changes to the files in a project. Git allows the same project to be worked on by people in different places. You can make changes to your local code and then distribute those changes to the other people working on the project.
+  As mentioned in the Contributing Guidelines, we have a simple style guide for our projects based on PEP 8. Give it a read to keep your code consistent with the rest of the codebase.
 
-  [Check out these resources to get started using Git](./working-with-git/)
+  [**Style Guide**](./style-guide/)
 
+### 6. Create an issue
+  The first step to any new contribution is an issue describing a problem with the current codebase or proposing a new feature. All the open issues are viewable on the GitHub repositories, for instance here is the [issues page for Sir Lancebot](https://github.com/python-discord/sir-lancebot/issues). If you have an idea that you want to implement, open a new issue (and check out our [**guide on writing an issue**](./issues/)). Otherwise you can browse the unassigned issues and ask to be assigned to one that you're interested in, either in the comments on the issue or in the`#dev-contrib`channel on Discord.
 
-If you don't understand anything or need clarification, feel free to ask any staff member with the  **@PyDis Core Developers** role in the server. We're always happy to help!
+  Don't move forward until your issue is approved by a Core Developer. Issues are not guaranteed to be approved so your work may be wasted.
+  {: .notification .is-warning }
+
+### 7. Open a pull request
+  After your issue has been approved and you've written your code and tested it, it's time to open a pull request. Pull requests are a feature in GitHub; you can think of them as asking the project maintainers to accept your changes. This gives other contributors a chance to review your code and make any needed changes before it's merged into the main branch of the project.
+
+  Check out our [**Pull Request Guide**](./contributing/pull-requests/) for help with opening a pull request and going through the review process.
+
+  Check out our [**Code Review Guide**](../code-reviews-primer/) to learn how to be a star reviewer. Reviewing PRs is a vital part of open source development, and we always need more reviewers!
+
+### That's it!
+Thank you for contributing to our community projects. If you don't understand anything or need clarification, feel free to ask a question in the`dev-contrib`channel and keep an eye out for staff members with the **@PyDis Core Developers** role in the server. We're always happy to help!
-- 
cgit v1.2.3
From e76e0a5f2d80a4f5ec31fe015155b0349f9f607b Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Sun, 20 Mar 2022 18:55:16 -0400
Subject: Fix PR page link
---
 pydis_site/apps/content/resources/guides/pydis-guides/contributing.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
index 759ed1dc..51991d9c 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
@@ -140,7 +140,7 @@ Our projects on Python Discord are open source and [available on GitHub](https:/
 ### 7. Open a pull request
   After your issue has been approved and you've written your code and tested it, it's time to open a pull request. Pull requests are a feature in GitHub; you can think of them as asking the project maintainers to accept your changes. This gives other contributors a chance to review your code and make any needed changes before it's merged into the main branch of the project.
 
-  Check out our [**Pull Request Guide**](./contributing/pull-requests/) for help with opening a pull request and going through the review process.
+  Check out our [**Pull Request Guide**](./pull-requests/) for help with opening a pull request and going through the review process.
 
   Check out our [**Code Review Guide**](../code-reviews-primer/) to learn how to be a star reviewer. Reviewing PRs is a vital part of open source development, and we always need more reviewers!
 
-- 
cgit v1.2.3
From 506ac4281dd150eefc4864a00838ac97aa71f14f Mon Sep 17 00:00:00 2001
From: Robin <74519799+Robin5605@users.noreply.github.com>
Date: Mon, 21 Mar 2022 14:15:50 -0500
Subject: Add hyperlinks
Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com>
---
 .../content/resources/guides/python-guides/vps-services.md | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
index 02feb8e6..07e70e9a 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
@@ -5,21 +5,21 @@ description: On different VPS services
 If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS).
 This is a list of VPS services that are sufficient for running Discord bots.
 
-* https://www.scaleway.com/
+* [https://www.scaleway.com](https://www.scaleway.com)
     * Based in Europe.
-* https://www.digitalocean.com/
+* [https://www.digitalocean.com](https://www.digitalocean.com)
     * US-based.
     * Considered by many to be the gold standard.
     * Locations available across the world.
-* https://www.ovh.co.uk/
+* [https://www.ovh.co.uk](https://www.ovh.co.uk)
     * France and Canadian locations available.
-* https://www.time4vps.eu/
+* [https://www.time4vps.eu](https://www.time4vps.eu)
     * Seemingly based in Lithuania.
-* https://www.linode.com/
+* [https://www.linode.com](https://www.linode.com)
     * Cheap VPS.
-* https://www.vultr.com/
+* [https://www.vultr.com](https://www.vultr.com)
     * US-based, DigitalOcean-like.
-* https://galaxygate.net/
+* [https://galaxygate.net](https://galaxygate.net)
     * A reliable, affordable, and trusted host.
 
 There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi (any model, except perhaps one of the particularly less powerful ones).
-- 
cgit v1.2.3
From cf8ce93e34ab543fdc9e4df83aa4583128c2ddc0 Mon Sep 17 00:00:00 2001
From: Shom770 <82843611+Shom770@users.noreply.github.com>
Date: Mon, 21 Mar 2022 18:42:04 -0400
Subject: add token safety pin
---
 .../guides/python-guides/keeping-tokens-safe.md    |  21 +++++++++++++++++++++
 .../resources/guides/python-guides/token-reset.png | Bin 0 -> 132625 bytes
 2 files changed, 21 insertions(+)
 create mode 100644 pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
 create mode 100644 pydis_site/apps/content/resources/guides/python-guides/token-reset.png
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
new file mode 100644
index 00000000..8e283d70
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
@@ -0,0 +1,21 @@
+---
+title: Keeping Discord Bot Tokens Safe
+description: How to keep your bot tokens safe and safety measures you can take.
+---
+It's **very** important to keep a bot token safe, primarily because anyone who has the bot token can do whatever they want with the bot -- such as destroying servers your bot has been added to and getting your bot banned from the API.
+
+# How to Avoid Leaking your Token
+To help prevent leaking your token, you should ensure that you don't upload it to an open source program/website, such as replit and github, as they show your code publicly. The best practice for storing tokens is generally utilising .env files ([click here](https://vcokltfre.dev/tips/tokens/.) for more information on storing tokens safely)
+
+# What should I do if my token does get leaked?
+
+If for whatever reason your token gets leaked, you should immediately follow these steps:
+- Go to the list of [Discord Bot Applications](https://discord.com/developers/applications) you have and select the bot application that had the token leaked.
+- Select the Bot (1) tab on the left-hand side, next to a small image of a puzzle piece. After doing so you should see a small section named TOKEN (under your bot USERNAME and next to his avatar image)
+- Press the Regenerate (2) option to regen your bot token.
+
+
+Following these steps will create a new token for your bot, making it secure again and terminating any connections from the leaked token.
+
+# Summary
+Make sure you keep your token secure by storing it safely, not sending it to anyone you don't trust, and regenerating your token if it does get leaked.
\ No newline at end of file
diff --git a/pydis_site/apps/content/resources/guides/python-guides/token-reset.png b/pydis_site/apps/content/resources/guides/python-guides/token-reset.png
new file mode 100644
index 00000000..bd672b93
Binary files /dev/null and b/pydis_site/apps/content/resources/guides/python-guides/token-reset.png differ
-- 
cgit v1.2.3
From 9ba4891e6e47f7f498eafa61e4a1d5e301f2426a Mon Sep 17 00:00:00 2001
From: Shom770 <82843611+Shom770@users.noreply.github.com>
Date: Mon, 21 Mar 2022 18:59:26 -0400
Subject: fixing image not appearing
---
 .../guides/python-guides/keeping-tokens-safe.md        |   3 ++-
 .../resources/guides/python-guides/token-reset.png     | Bin 132625 -> 0 bytes
 2 files changed, 2 insertions(+), 1 deletion(-)
 delete mode 100644 pydis_site/apps/content/resources/guides/python-guides/token-reset.png
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
index 8e283d70..e37039d1 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
@@ -13,7 +13,8 @@ If for whatever reason your token gets leaked, you should immediately follow the
 - Go to the list of [Discord Bot Applications](https://discord.com/developers/applications) you have and select the bot application that had the token leaked.
 - Select the Bot (1) tab on the left-hand side, next to a small image of a puzzle piece. After doing so you should see a small section named TOKEN (under your bot USERNAME and next to his avatar image)
 - Press the Regenerate (2) option to regen your bot token.
-
+
+
 
 Following these steps will create a new token for your bot, making it secure again and terminating any connections from the leaked token.
 
diff --git a/pydis_site/apps/content/resources/guides/python-guides/token-reset.png b/pydis_site/apps/content/resources/guides/python-guides/token-reset.png
deleted file mode 100644
index bd672b93..00000000
Binary files a/pydis_site/apps/content/resources/guides/python-guides/token-reset.png and /dev/null differ
-- 
cgit v1.2.3
From 00a17a53c542b6de263691219c38438393d9f806 Mon Sep 17 00:00:00 2001
From: Robin5605 
Date: Tue, 22 Mar 2022 12:33:13 -0500
Subject: Update VPS services content
---
 .../resources/guides/python-guides/vps-services.md | 53 +++++++++++++---------
 1 file changed, 32 insertions(+), 21 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
index 07e70e9a..4dfca732 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
@@ -1,25 +1,36 @@
 ---
-title: VPS Services
-description: On different VPS services
+ title: VPS Services
+ description: On different VPS services
 ---
-If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS).
-This is a list of VPS services that are sufficient for running Discord bots.
 
-* [https://www.scaleway.com](https://www.scaleway.com)
-    * Based in Europe.
-* [https://www.digitalocean.com](https://www.digitalocean.com)
-    * US-based.
-    * Considered by many to be the gold standard.
-    * Locations available across the world.
-* [https://www.ovh.co.uk](https://www.ovh.co.uk)
-    * France and Canadian locations available.
-* [https://www.time4vps.eu](https://www.time4vps.eu)
-    * Seemingly based in Lithuania.
-* [https://www.linode.com](https://www.linode.com)
-    * Cheap VPS.
-* [https://www.vultr.com](https://www.vultr.com)
-    * US-based, DigitalOcean-like.
-* [https://galaxygate.net](https://galaxygate.net)
-    * A reliable, affordable, and trusted host.
+If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS).This is a list of VPS services that are sufficient for running Discord bots.
+
+* Europe
+    * [netcup](https://www.netcup.eu/)
+        * Germany & Austria data centres.
+        * Great affiliate program.
+    * [Yandex Cloud](https://cloud.yandex.ru/)
+        * Vladimir, Ryazan, and Moscow region data centres.
+    * [Scaleway](https://www.scaleway.com/)
+        * France data centre.
+    * [Time 4 VPS](https://www.time4vps.eu/)
+        * Lithuania data centre.
+* US
+    * [GalaxyGate](https://galaxygate.net/)
+        * New York data centre.
+        * Great affiliate program.
+* Global
+    * [Linode](https://www.linode.com/)
+        * Global data centre options.
+    * [Digital Ocean](https://www.digitalocean.com/)
+        * Global data centre options.
+    * [OVHcloud](https://www.ovhcloud.com/)
+        * Global data centre options.
+    * [Vultr](https://www.vultr.com/)
+        * Global data centre options.
+
+---
+Free hosts
+There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting.
+Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi.
 
-There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi (any model, except perhaps one of the particularly less powerful ones).
-- 
cgit v1.2.3
From 3ff628ad44b80f1c5483832f72ee8b63bcbc4fdb Mon Sep 17 00:00:00 2001
From: D0rs4n <41237606+D0rs4n@users.noreply.github.com>
Date: Tue, 22 Mar 2022 18:55:02 +0100
Subject: Add UniqueConstraint to the Filter model
- The UniqueConstraint includes every field, except for id and description.
---
 .../migrations/0080_unique_constraint_filters.py   | 36 ++++++++++++++++++++++
 pydis_site/apps/api/models/bot/filters.py          | 28 ++++++++++++++++-
 2 files changed, 63 insertions(+), 1 deletion(-)
 create mode 100644 pydis_site/apps/api/migrations/0080_unique_constraint_filters.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0080_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0080_unique_constraint_filters.py
new file mode 100644
index 00000000..0b3b4162
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0080_unique_constraint_filters.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.1.14 on 2022-03-22 16:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0079_new_filter_schema'),
+    ]
+
+    operations = [
+        migrations.AddConstraint(
+            model_name='filter',
+            constraint=models.UniqueConstraint(fields=(
+                'dm_content',
+                'dm_embed',
+                'infraction_type',
+                'infraction_reason',
+                'infraction_duration',
+                'content',
+                'additional_field',
+                'filter_list',
+                'guild_pings',
+                'filter_dm',
+                'dm_pings',
+                'delete_messages',
+                'bypass_roles',
+                'enabled',
+                'send_alert',
+                'enabled_channels',
+                'disabled_channels',
+                'disabled_categories'
+            ), name='unique_filters'),
+        ),
+    ]
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
index f8bbfd14..708ceadc 100644
--- a/pydis_site/apps/api/models/bot/filters.py
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -113,7 +113,7 @@ class FilterList(FilterSettingsMixin):
         return f"Filter {FilterListType(self.list_type).label}list {self.name!r}"
 
 
-class Filter(FilterSettingsMixin):
+class FilterBase(FilterSettingsMixin):
     """One specific trigger of a list."""
 
     content = models.CharField(max_length=100, help_text="The definition of this filter.")
@@ -173,3 +173,29 @@ class Filter(FilterSettingsMixin):
 
     def __str__(self) -> str:
         return f"Filter {self.content!r}"
+
+    class Meta:
+        """Metaclass for FilterBase to make it abstract model."""
+
+        abstract = True
+
+
+class Filter(FilterBase):
+    """
+    The main Filter models based on `FilterBase`.
+
+    The purpose to have this model is to have access to the Fields of the Filter model
+    and set the unique constraint based on those fields.
+    """
+
+    class Meta:
+        """Metaclass Filter to set the unique constraint."""
+
+        constraints = (
+            UniqueConstraint(
+                fields=tuple(
+                    [field.name for field in FilterBase._meta.fields
+                     if field.name != "id" and field.name != "description"]
+                ),
+                name="unique_filters"),
+        )
-- 
cgit v1.2.3
From aedf67e6f141288f1f8b00bbd0e46cae1eb16a6c Mon Sep 17 00:00:00 2001
From: Robin5605 
Date: Tue, 22 Mar 2022 14:22:27 -0500
Subject: Fix EOF
---
 pydis_site/apps/content/resources/guides/python-guides/vps-services.md | 1 -
 1 file changed, 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
index 4dfca732..95951134 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
@@ -33,4 +33,3 @@ If you need to run your bot 24/7 (with no downtime), you should consider using a
 Free hosts
 There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting.
 Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi.
-
-- 
cgit v1.2.3
From 682613bcf56ea6aefac8b15b56064a16732894a4 Mon Sep 17 00:00:00 2001
From: Robin <74519799+Robin5605@users.noreply.github.com>
Date: Tue, 22 Mar 2022 23:51:36 -0500
Subject: Remove starting whitespaces
Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com>
---
 .../apps/content/resources/guides/python-guides/vps-services.md       | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
index 95951134..90306057 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
@@ -1,6 +1,6 @@
 ---
- title: VPS Services
- description: On different VPS services
+title: VPS Services
+description: On different VPS services
 ---
 
 If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS).This is a list of VPS services that are sufficient for running Discord bots.
-- 
cgit v1.2.3
From 806ce3fdf433d05b9d480a3d7ca2332dac951194 Mon Sep 17 00:00:00 2001
From: Krypton 
Date: Wed, 23 Mar 2022 22:35:32 +0100
Subject: Update discord-messages-with-colors.md
---
 .../python-guides/discord-messages-with-colors.md   | 21 +++++++++++----------
 1 file changed, 11 insertions(+), 10 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
index 30f40948..c8e50d71 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
@@ -3,13 +3,13 @@ title: Discord Messages with Colors
 description: A guide on how to add colors to your codeblocks on Discord
 ---
 
-Discord is now slowly rolling out the ability to send colored messages within code blocks. It uses the ANSI color codes, so if you've tried to print colored text in your terminal or console with Python or other languages then it will be easy for you.
+Discord is now slowly rolling out the ability to send colored text within code blocks. This is done using ANSI color codes which is also how you print colored text in your terminal.
 
-To be able to send a colored text, you need to use the `ansi` language for your code block and provide a prefix of this format before writing your text:
-```
+To send colored text in a code block you need to first specify the `ansi` language and use the prefixes similar to the one below:
+```ansi
 \u001b[{format};{color}m
 ```
-*The `\u001b` is the unicode for ESCAPE/ESC, see .* ***If you want to use it yourself without bots, then you need to copy paste the character from the website.***
+*`\u001b` is the unicode escape for ESCAPE/ESC, meant to be used in the source of your bot (see ).* ***If you wish to send colored text without using your bot you need to copy the character from the website.***
 
 After you've written this, you can type and text you wish, and if you want to reset the color back to normal, then you need to use `\u001b[0m` as prefix.
 
@@ -34,13 +34,13 @@ Here is the list of values you can use to replace `{color}`:
 
 *The following values will change the **text background** color.*
 
-* 40: Some very dark blue
+* 40: Firefly dark blue
 * 41: Orange
-* 42: Gray
-* 43: Light gray
-* 44: Even lighter gray
+* 42: Marble blue
+* 43: Greyish turquoise
+* 44: Gray
 * 45: Indigo
-* 46: Again some gray
+* 46: Light gray
 * 47: White
 
 Let's take an example, I want a bold green colored text with the very dark blue background.
@@ -54,7 +54,8 @@ or
 \u001b[1;40;32mThat's some cool formatted text right?
 \`\`\`
 
-Result:
+Result:
+
 
 
 The way the colors look like on Discord is shown in the image below:
-- 
cgit v1.2.3
From 622263827ba2c44739dccb035943c48489f3166e Mon Sep 17 00:00:00 2001
From: Johannes Christ 
Date: Fri, 25 Mar 2022 10:23:07 +0100
Subject: Fix link to code jam image
Closes #704.
---
 pydis_site/templates/events/sidebar/code-jams/7.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/sidebar/code-jams/7.html b/pydis_site/templates/events/sidebar/code-jams/7.html
index d4615c2a..4aefdbd9 100644
--- a/pydis_site/templates/events/sidebar/code-jams/7.html
+++ b/pydis_site/templates/events/sidebar/code-jams/7.html
@@ -1,7 +1,7 @@
 {% load static %}
 
 
-    

+    
     
     
         
-- 
cgit v1.2.3
From 1017676bea05d423d86a29c2ede769a6288a3d4a Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Sat, 26 Mar 2022 22:33:03 -0400
Subject: Fix commit-messages link
---
 .../guides/pydis-guides/contributing/contributing-guidelines.md         | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md
index f13b05be..1917ee43 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md
@@ -12,7 +12,7 @@ We have simple but strict style rules that are enforced through linting.
 Not all of the style rules are enforced by linting, so make sure to read the [style guide](../style-guide/) as well.
 2. **Make great commits.**
 Great commits should be atomic, with a commit message explaining what and why.
-Check out [Writing Good Commit Messages](/commit-messages) for details.
+Check out [Writing Good Commit Messages](./commit-messages) for details.
 3. **Do not open a pull request if you aren't assigned to the issue.**
 If someone is already working on it, consider offering to collaborate with that person.
 4. **Use assets licensed for public use.**
-- 
cgit v1.2.3
From 15a32db18b72291913cf80b9fc85ac578b824f34 Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Sun, 27 Mar 2022 00:06:14 -0400
Subject: Make small wording improvements
---
 .../apps/content/resources/guides/pydis-guides/contributing.md       | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
index 51991d9c..4b53d978 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
@@ -92,7 +92,7 @@ Our projects on Python Discord are open source and [available on GitHub](https:/
  
 
 # How do I start contributing?
-  Unsure of what contributing to open source projects involves? Have questions about how to use GitHub? Just need to know about our contribution etiquette? Completing these steps will have you ready to make your first contribution.
+  Unsure of what contributing to open source projects involves? Have questions about how to use GitHub? Just need to know about our contribution etiquette? Completing these steps will have you ready to make your first contribution no matter your starting point.
 
   Feel free to skip any steps you're already familiar with, but please make sure not to miss the  [Contributing Guidelines](#5-read-our-contributing-guidelines).
 
@@ -107,7 +107,6 @@ Our projects on Python Discord are open source and [available on GitHub](https:/
   GitHub is a website based on the Git version control system that stores project files in the cloud. The people working on the project can use GitHub as a central place for sending their changes, getting their teammates' changes, and communicating with each other. Forking the repository that you want to work on will create a copy under your own GitHub account. You'll make your changes to this copy, then later we can bring them back to the PyDis repository.
 
   Check out our [**guide on forking a GitHub repo**](./forking-repository/).
-
 ### 3. Clone the repo
   Now that you have your own fork you need to be able to make changes to the code. You can clone the repo to your local machine, commit changes to it there, then push those changes to GitHub.
 
@@ -145,4 +144,4 @@ Our projects on Python Discord are open source and [available on GitHub](https:/
   Check out our [**Code Review Guide**](../code-reviews-primer/) to learn how to be a star reviewer. Reviewing PRs is a vital part of open source development, and we always need more reviewers!
 
 ### That's it!
-Thank you for contributing to our community projects. If you don't understand anything or need clarification, feel free to ask a question in the`dev-contrib`channel and keep an eye out for staff members with the **@PyDis Core Developers** role in the server. We're always happy to help!
+Thank you for contributing to our community projects. If there's anything you don't understand or you just want to discuss with other contributors, come visit the`dev-contrib`channel to ask questions. Keep an eye out for staff members with the **@PyDis Core Developers** role in the server; we're always happy to help!
-- 
cgit v1.2.3
From 08d5e01be09e482f92a96cf00bc007e60dfa2c98 Mon Sep 17 00:00:00 2001
From: Robin <74519799+Robin5605@users.noreply.github.com>
Date: Thu, 31 Mar 2022 10:46:33 -0500
Subject: Make "free hosts" a header
Co-authored-by: Shom770 <82843611+Shom770@users.noreply.github.com>
---
 pydis_site/apps/content/resources/guides/python-guides/vps-services.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
index 90306057..b8a6f8fd 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
@@ -30,6 +30,6 @@ If you need to run your bot 24/7 (with no downtime), you should consider using a
         * Global data centre options.
 
 ---
-Free hosts
+# Free hosts
 There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting.
 Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi.
-- 
cgit v1.2.3
From 0cc092741ea35b5810d57d4d768dcb0e021603bd Mon Sep 17 00:00:00 2001
From: Robin5605 
Date: Thu, 31 Mar 2022 10:50:38 -0500
Subject: Remove extraneous bullet points in "global"
---
 .../apps/content/resources/guides/python-guides/vps-services.md       | 4 ----
 1 file changed, 4 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
index b8a6f8fd..bd4af0a5 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
@@ -21,13 +21,9 @@ If you need to run your bot 24/7 (with no downtime), you should consider using a
         * Great affiliate program.
 * Global
     * [Linode](https://www.linode.com/)
-        * Global data centre options.
     * [Digital Ocean](https://www.digitalocean.com/)
-        * Global data centre options.
     * [OVHcloud](https://www.ovhcloud.com/)
-        * Global data centre options.
     * [Vultr](https://www.vultr.com/)
-        * Global data centre options.
 
 ---
 # Free hosts
-- 
cgit v1.2.3
From 1e916a0515940caa95f34fc0bc04a040b36c214d Mon Sep 17 00:00:00 2001
From: Robin <74519799+Robin5605@users.noreply.github.com>
Date: Thu, 31 Mar 2022 11:52:57 -0500
Subject: Add space
Co-authored-by: Hassan Abouelela 
---
 pydis_site/apps/content/resources/guides/python-guides/vps-services.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
index bd4af0a5..0acd3e55 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md
@@ -3,7 +3,7 @@ title: VPS Services
 description: On different VPS services
 ---
 
-If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS).This is a list of VPS services that are sufficient for running Discord bots.
+If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS). This is a list of VPS services that are sufficient for running Discord bots.
 
 * Europe
     * [netcup](https://www.netcup.eu/)
-- 
cgit v1.2.3
From 1005229e825a5f2449168bcae2b85667c5799265 Mon Sep 17 00:00:00 2001
From: Krypton 
Date: Fri, 1 Apr 2022 18:15:19 +0200
Subject: Added Discord embed limits guide (#690)
Co-authored-by: Krypton 
Co-authored-by: Krypton 
Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com>
---
 .../guides/python-guides/discord-embed-limits.md    | 21 +++++++++++++++++++++
 1 file changed, 21 insertions(+)
 create mode 100644 pydis_site/apps/content/resources/guides/python-guides/discord-embed-limits.md
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-embed-limits.md b/pydis_site/apps/content/resources/guides/python-guides/discord-embed-limits.md
new file mode 100644
index 00000000..ca97462b
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-embed-limits.md
@@ -0,0 +1,21 @@
+---
+title: Discord Embed Limits
+description: A guide that shows the limits of embeds in Discord and how to avoid them.
+---
+
+If you plan on using embed responses for your bot you should know the limits of the embeds on Discord or you will get `Invalid Form Body` errors:
+
+- Embed **title** is limited to **256 characters**
+- Embed **description** is limited to **4096 characters**
+- An embed can contain a maximum of **25 fields**
+- A **field name/title** is limited to **256 character** and the **value of the field** is limited to **1024 characters**
+- Embed **footer** is limited to **2048 characters**
+- Embed **author name** is limited to **256 characters**
+- The **total of characters** allowed in an embed is **6000**
+
+Now if you need to get over this limit (for example for a help command), you would need to use pagination.
+There are several ways to do that:
+
+- A library called **[disputils](https://pypi.org/project/disputils)**
+- An experimental library made by the discord.py developer called **[discord-ext-menus](https://github.com/Rapptz/discord-ext-menus)**
+- Make your own setup using **[wait_for()](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.Bot.wait_for)** and wait for a reaction to be added
-- 
cgit v1.2.3
From 306a906c850324a4a31a62d82d5348a18f3f2e5e Mon Sep 17 00:00:00 2001
From: Shom770 <82843611+Shom770@users.noreply.github.com>
Date: Sat, 2 Apr 2022 12:16:13 -0400
Subject: Update
 pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com>
---
 .../apps/content/resources/guides/python-guides/keeping-tokens-safe.md  | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
index e37039d1..6b5dae34 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
@@ -12,7 +12,7 @@ To help prevent leaking your token, you should ensure that you don't upload it t
 If for whatever reason your token gets leaked, you should immediately follow these steps:
 - Go to the list of [Discord Bot Applications](https://discord.com/developers/applications) you have and select the bot application that had the token leaked.
 - Select the Bot (1) tab on the left-hand side, next to a small image of a puzzle piece. After doing so you should see a small section named TOKEN (under your bot USERNAME and next to his avatar image)
-- Press the Regenerate (2) option to regen your bot token.
+- Press the Regenerate option to regen your bot token.
 
 
 
-- 
cgit v1.2.3
From 788c8fc1b3fc54fa35dc8282b8a1267314e85a77 Mon Sep 17 00:00:00 2001
From: Cam Caswell 
Date: Thu, 7 Apr 2022 22:44:09 -0400
Subject: Replace difficulty badges with more description
---
 .../resources/guides/pydis-guides/contributing.md      | 18 +++---------------
 1 file changed, 3 insertions(+), 15 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
index 4b53d978..e4b3e621 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md
@@ -19,11 +19,7 @@ Our projects on Python Discord are open source and [available on GitHub](https:/
       
       
         
-          Our community-driven Discord bot.
-        
-        
-          Difficulty
-          Beginner
+          Sir Lancebot has a collection of self-contained, for-fun features. If you're new to Discord bots or contributing, this is a great place to start!
         
        
       
       
         
-          The community and moderation Discord bot.
-        
-        
-          Difficulty
-          Intermediate
+          Called @Python on the server, this bot handles moderation tools, help channels, and other critical features for our community.
         
        
       
       
         
-          The website, subdomains and API.
-        
-        
-          Difficulty
-          Advanced
+          This website itself! This project is built with Django and includes our API, which is used by various services such as @Python.
         
        
       
 
-- 
cgit v1.2.3
From 382f5cc427dedc95056e04db38980a6a9fd15f73 Mon Sep 17 00:00:00 2001
From: kosayoda 
Date: Sun, 26 Jun 2022 23:05:13 -0400
Subject: Improve icon size and alignment.
---
 pydis_site/static/css/home/index.css | 3 +--
 pydis_site/templates/home/index.html | 4 ++--
 2 files changed, 3 insertions(+), 4 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/static/css/home/index.css b/pydis_site/static/css/home/index.css
index e1d70370..e117a35b 100644
--- a/pydis_site/static/css/home/index.css
+++ b/pydis_site/static/css/home/index.css
@@ -126,8 +126,7 @@ h1 {
     margin: 0 4% 0 4%;
     background-color: #3EB2EF;
     color: white;
-    font-size: 15px;
-    line-height: 33px;
+    line-height: 31px;
     border:none;
     box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
     transition: all 0.3s cubic-bezier(.25,.8,.25,1);
diff --git a/pydis_site/templates/home/index.html b/pydis_site/templates/home/index.html
index 33c84a97..fcbb87e4 100644
--- a/pydis_site/templates/home/index.html
+++ b/pydis_site/templates/home/index.html
@@ -99,9 +99,9 @@
             
               
               
-              
+              
               
-              
+              
               
             
 
-- 
cgit v1.2.3
From 4ec0fb3e6102c0f5a99c51afcec38eb5d0dfa6ae Mon Sep 17 00:00:00 2001
From: Blue-Puddle 
Date: Tue, 28 Jun 2022 06:23:50 +0100
Subject: added discord-app-commands.md
---
 .../guides/python-guides/discord-app-commands.md   | 451 +++++++++++++++++++++
 1 file changed, 451 insertions(+)
 create mode 100644 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
new file mode 100644
index 00000000..79d224a1
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
@@ -0,0 +1,451 @@
+---
+title: Slash Commands with discord.py!
+description: A simple guide to creating slash commands within discord.py!
+---
+# DISCORD.PY RESUMPTION CHANGES
+
+---
+
+Upon resumption of the most popular discord API wrapper library for python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with addition of features to the library. Some additions to the library are -> Buttons support, Select Menus Support, Forms (AKA Modals), Slash Commands (AKA Application Commands) and a bunch of more handy features! All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py gist regarding resumption can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6).
+
+# Why this gist?
+
+---
+
+This Gist is being created as an update to slash commands (app commands) with explanation and examples. This Gist mainly focuses on **SLASH COMMANDS** for discord.py 2.0 (and above)!
+
+# What Are Slash Commands?
+
+---
+
+Slash Commands are the exciting way to build and interact with bots on Discord. With Slash Commands, all you have to do is type / and you're ready to use your favourite bot. You can easily see all the commands a bot has, and validation and error handling help you get the command right the first time.
+
+# Install the latest version for discord.py
+
+---
+To use slash commands with discord.py. The latest up-to-date version has to be installed. Make sure that the version is 2.0 or above!
+And make sure to uninstall any third party libraries that support slash commands for discord.py (if any) as a few of them [monkey patch](https://en.wikipedia.org/wiki/Monkey_patch#:~:text=A%20monkey%20patch%20is%20a,running%20instance%20of%20the%20program) discord.py!
+
+The latest and up-to-date usable discord.py version can be installed using `pip install -U git+https://github.com/Rapptz/discord.py`.
+
+If you get an error such as: `'git' is not recognized...`. [Install git](https://git-scm.com/downloads) for your platform. Go through the required steps for installing git and make sure to enable the `add to path` option while in the installation wizard. After installing git you can run `pip install -U git+https://github.com/Rapptz/discord.py` again to install discord.py 2.0.
+**BEFORE MIGRATING TO DISCORD.PY 2.0, PLEASE READ THE CONSEQUENCES OF THE UPDATE [HERE](https://discordpy.readthedocs.io/en/latest/migrating.html)**.
+
+# Basic Structure for Discord.py Slash Commands!
+
+---
+
+### Note that Slash Commands in discord.py are also referred to as **Application Commmands** and **App Commands** and every *interaction* is a *webhook*.
+Slash commands in discord.py are held by a container, [CommandTree](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=commandtree#discord.app_commands.CommandTree). A command tree is required to create slash commands in discord.py. This command tree provides a `command` method which decorates an asynchronous function indicating to discord.py that the decorated function is intended to be a slash command. This asynchronous function expects a default argument which acts as the interaction which took place that invoked the slash command. This default argument is an instance of the **Interaction** class from discord.py. Further up, the command logic takes over the behaviour of the slash command.
+
+# Fundamentals for this gist!
+
+---
+
+
+The fundamental for this gist will remain to be the `setup_hook`. The `setup_hook` is a special asynchronous method of the Client and Bot class which can be overwritten to perform numerous tasks. This method is safe to use as it is always triggered before any events are dispatched, i.e. this method is triggered before the *IDENTIFY* payload is sent to the discord gateway.
+Note that methods of the Bot class such as `change_presence` will not work in setup_hook as the current application does not have an active connection to the gateway at this point.
+
+__**FOLLOWING IS THE EXAMPLE OF HOW A `SETUP_HOOK` FUNCTION CAN BE DEFINED**__
+
+```python
+import discord
+
+'''This is one way of creating a "setup_hook" method'''
+
+class SlashClient(discord.Client):
+    def __init__(self) -> None:
+        super().__init__(intents=discord.Intents.default())
+    
+    async def setup_hook(self) -> None:
+        #perform tasks
+
+'''Another way of creating a "setup_hook" is as follows'''
+ 
+client = discord.Client(intents=discord.Intents.default())
+async def my_setup_hook() -> None:
+    #perform tasks
+
+client.setup_hook = my_setup_hook
+```
+
+# Basic Slash Command application using discord.py.
+
+#### The `CommandTree` class resides within the `app_commands` of discord.py package.
+---
+
+## Slash Command Application with a Client
+
+```python
+import discord
+
+class SlashClient(discord.Client):
+    def __init__(self) -> None:
+        super().__init__(intents=discord.Intents.default())
+        self.tree = discord.app_commands.CommandTree(self)
+    
+    async def setup_hook(self) -> None:
+        self.tree.copy_global_to(guild=discord.Object(id=12345678900987654))
+        await self.tree.sync()
+
+client = SlashClient()
+
+@client.tree.command(name="ping", description="...")
+async def _ping(interaction: discord.Interaction) -> None:
+    await interaction.response.send_message("pong")
+
+client.run("token")
+```
+
+
+__**EXPLANATION**__
+
+- `import discord` imports the **discord.py** package. 
+- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid. 
+- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commaands. 
+- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. 
+- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`. 
+- Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker.
+- And the classic old `client.run("token")` is used to connect the client to the discord gateway.
+- Note that the `send_message` is a method of the `InteractionResponse` class and `interaction.response` in this case is an instance of the `InteractionResponse` object. The `send_message` method will not function if the response is not sent within 3 seconds of command invocation. I will discuss about how to handle this issue later following the gist.
+
+## Slash Command Application with a Bot
+
+```python
+import discord
+
+class SlashBot(commands.Bot):
+    def __init__(self) -> None:
+        super().__init__(command_prefix=".", intents=discord.Intents.default())
+        
+    async def setup_hook(self) -> None:
+        self.tree.copy_global_to(guild=discord.Object(id=12345678900987654))
+        await self.tree.sync()
+
+bot = SlashBot()
+
+@bot.tree.command(name="ping", description="...")
+async def _ping(interaction: discord.Interaction) -> None:
+    await interaction.response.send_message("pong")
+
+bot.run("token")
+```
+
+The above example shows a basic slash commands within discord.py using the Bot class.
+
+__**EXPLANATION**__
+
+Most of the explanation is the same as the prior example which featured `SlashClient` which was a subclass of **discord.Client**. Though some minor changes are discussed below.
+
+- The `SlashBot` class now subclasses `discord.ext.commands.Bot` following the passing in of the required arguments to its `__init__` method.
+- `discord.ext.commands.Bot` already consists of an instance of the `CommandTree` class which can be accessed using the `tree` property.
+
+# Slash Commands within a Cog!
+
+---
+
+A cog is a collection of commands, listeners, and optional state to help group commands together. More information on them can be found on the [Cogs](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs) page.
+
+## An Example to using cogs with discord.py for slash commands!
+
+```python
+import discord
+from discord.ext import commands
+from discord import app_commands
+
+class MySlashCog(commands.Cog):
+    def __init__(self, bot: commands.Bot) -> None:
+        self.bot = bot
+    
+    @app_commands.command(name="ping", description="...")
+    async def _ping(self, interaction: discord.Interaction):
+        await interaction.response.send_message("pong!")
+    
+class MySlashBot(commands.Bot):
+    def __init__(self) -> None:
+        super().__init__(command_prefix="!", intents=discord.Intents.default())
+    
+    async def setup_hook(self) -> None:
+        await self.add_cog(MySlashCog(self))
+        await self.tree.copy_global_to(discord.Object(id=123456789098765432))
+        await self.tree.sync()
+        
+bot = MySlashBot()
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+
+- Firstly, `import discord` imports the **discord.py** package. `from discord import app_commands` imports the `app_commands` directory from the **discord.py** root directory. `from discord.ext import commands` imports the commands extension.
+- Further up, `class MySlashCog(commands.Cog)` is a class subclassing the `Cog` class. You can read more about this [here](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs).
+- `def __init__(self, bot: commands.Bot): self.bot = bot` is the constructor method of the class that is always run when the class is instantiated and that is why we pass in a **Bot** object whenever we create an instance of the cog class.
+- Following up is the `@app_commands.command(name="ping", description="...")` decorator. This decorator basically functions the same as a `bot.tree.command` but since the cog currently does not have a **bot**, the `app_commands.command` decorator is used instead. The next two lines follow the same structure for slash commands with **self** added as the first parameter to the function as it is a method of a class.
+- The next up lines are mostly the same.
+- Talking about the first line inside the `setup_hook` is the `add_cog` method of the **Bot** class. And since **self** acts as the **instance** of the current class, we use **self** to use the `add_cog` method of the **Bot** class as we are inside a subclassed class of the **Bot** class. Then we pass in **self** to the `add_cog` method as the `__init__` function of the **MySlashCog** cog accepts a `Bot` object.
+- After that we instantiate the `MySlashBot` class and run the bot using the **run** method which executes our setup_hook function and our commands get loaded and synced. The bot is now ready to use!
+
+# An Example to using groups with discord.py for slash commands!
+
+---
+
+## An example with optional group!
+
+```python
+import discord
+from discord.ext import commands
+from discord import app_commands
+
+class MySlashGroupCog(commands.Cog):
+    def __init__(self, bot: commands.Bot) -> None:
+        self.bot = bot
+
+    #--------------------------------------------------------
+    group = app_commands.Group(name="uwu", description="...")
+    #--------------------------------------------------------
+
+    @app_commands.command(name="ping", description="...")
+    async def _ping(self, interaction: discord.) -> None:
+        await interaction.response.send_message("pong!")
+    
+    @group.command(name="command", description="...")
+    async def _cmd(self, interaction: discord.Interaction) -> None:
+        await interaction.response.send_message("uwu")
+    
+class MySlashBot(commands.Bot):
+    def __init__(self) -> None:
+        super().__init__(command_prefix="!", intents=discord.Intents.default())
+    
+    async def setup_hook(self) -> None:
+        await self.add_cog(MySlashGroupCog(self))
+        await self.tree.copy_global_to(discord.Object(id=123456789098765432))
+        await self.tree.sync()
+        
+bot = MySlashBot()
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+- The only difference used here is `group = app_commands.Group(name="uwu", description="...")` and `group.command`. `app_commands.Group` is used to initiate a group while `group.command` registers a command under a group. For example, the ping command can be run using **/ping** but this is not the case for group commands. They are registered with the format of `group_name command_name`. So here, the **command** command of the **uwu** group would be run using **/uwu command**. Note that only group commands can have a single space between them.
+
+---
+
+## An example with a **Group** subclass!
+
+```python
+import discord
+from discord.ext import commands
+from discord import app_commands
+
+class MySlashGroup(commands.GroupCog, name="uwu"):
+    def __init__(self, bot: commands.Bot) -> None:
+        self.bot = bot
+        super().__init__()
+
+    @app_commands.command(name="ping", description="...")
+    async def _ping(self, interaction: discord.) -> None:
+        await interaction.response.send_message("pong!")
+    
+    @app_commands.command(name="command", description="...")
+    async def _cmd(self, interaction: discord.Interaction) -> None:
+        await interaction.response.send_message("uwu")
+    
+class MySlashBot(commands.Bot):
+    def __init__(self) -> None:
+        super().__init__(command_prefix="!", intents=discord.Intents.default())
+    
+    async def setup_hook(self) -> None:
+        await self.add_cog(MySlashGroup(self))
+        await self.tree.copy_global_to(discord.Object(id=123456789098765432))
+        await self.tree.sync()
+        
+bot = MySlashBot()
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+- The only difference here too is that the `MySlashGroup` class directly subclasses the **GroupCog** class from discord.ext.commands which automatically registers all the methods within the group class to be commands of that specific group. So now, the commands such as `ping` can be run using **/uwu ping** and `command` using **/uwu command**.
+
+
+# Some common methods and features used for slash commands.
+
+--- 
+
+### A common function used for slash commands is the `describe` function. This is used to add descriptions to the arguments of a slash command. The command function can decorated with this function. It goes by the following syntax as shown below.
+
+```python
+from discord.ext import commands
+from discord import app_commands
+import discord
+
+bot = commands.Bot(command_prefix=".", intents=discord.Intents.default())
+#sync the commands
+
+@bot.tree.command(name="echo", description="...")
+@app_commands.describe(text="The text to send!", channel="The channel to send the message in!")
+async def _echo(interaction: discord.Interaction, text: str, channel: discord.TextChannel=None):
+    channel = interaction.channel or channel
+    await channel.send(text)
+```
+
+### Another common issue that most people come across is the time duraction of sending a message with `send_message`. This issue can be tackled by deferring the interaction response using the `defer` method of the `InteractionResponse` class. An example for fixing this issue is shown below.
+
+```python
+import discord
+from discord.ext import commands
+import asyncio
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync the commands
+
+@bot.tree.command(name="time", description="...")
+async def _time(interaction: discord.Interaction, time_to_wait: int):
+    # -------------------------------------------------------------
+    await interaction.response.defer(ephemeral=True, thinking=True)
+    # -------------------------------------------------------------
+    await interaction.edit_original_message(content=f"I will notify you after {time_to_wait} seconds have passed!")
+    await asyncio.sleep(time_to_wait)
+    await interaction.edit_original_message(content=f"{interaction.user.mention}, {time_to_wait} seconds have already passed!")
+```
+
+# Checking for Permissions and Roles!
+
+---
+
+To add a permissions check to a command, the methods are imported through `discord.app_commands.checks`. To check for a member's permissions, the function can be decorated with the [discord.app_commands.checks.has_permissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=has_permissions#discord.app_commands.checks.has_permissions) method. An example to this as follows.
+
+```py
+from discord import app_commands
+from discord.ext import commands
+import discord
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync commands
+
+@bot.tree.command(name="ping")
+@app_commands.checks.has_permissions(manage_messages=True, manage_channels=True) #example permissions
+async def _ping(interaction: discord.Interaction):
+    await interaction.response.send_message("pong!")
+
+```
+
+If the check fails, it will raise a `MissingPermissions` error which can be handled within an app commands error handler! I will discuss about making an error handler later in the gist. All the permissions can be found [here](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discord%20permissions#discord.Permissions).
+
+Other methods that you can decorate the commands with are -
+- `bot_has_permissions` | This checks if the bot has the required permissions for executing the slash command. This raises a [BotMissingPermissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.BotMissingPermissions) exception.
+- `has_role` | This checks if the slash command user has the required role or not. Only **ONE** role name or role ID can be passed to this. If the name is being passed, make sure to have the exact same name as the role name. This raises a [MissingRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingRole) exception.
+- To pass in several role names or role IDs, `has_any_role` can be used to decorate a command. This raises two exceptions -> [MissingAnyRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingAnyRole) and [NoPrivateMessage](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.NoPrivateMessage)
+
+
+# Adding cooldowns to slash commands!
+
+---
+
+Slash Commands within discord.py can be applied cooldowns to in order to prevent spamming of the commands. This can be done through the `discord.app_commands.checks.cooldown` method which can be used to decorate a slash command function and register a cooldown to the function. This raises a [CommandOnCooldown](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=checks%20cooldown#discord.app_commands.CommandOnCooldown) exception if the command is currently on cooldown.
+An example is as follows.
+
+```python
+from discord.ext import commands
+import discord
+
+class Bot(commands.Bot):
+    def __init__(self):
+        super().__init__(command_prefix="uwu", intents=discord.Intents.all())
+        
+    
+    async def setup_hook(self):
+        self.tree.copy_global_to(guild=discord.Object(id=12345678909876543)) 
+        await self.tree.sync()
+        
+    
+bot = Bot()
+
+@bot.tree.command(name="ping")
+# -----------------------------------------
+@discord.app_commands.checks.cooldown(1, 30)
+# -----------------------------------------
+async def ping(interaction: discord.Interaction):
+    await interaction.response.send_message("pong!")
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+- The first argument the `cooldown` method takes is the amount of times the command can be run in a specific period of time.
+- The second argument it takes is the period of time in which the command can be run the specified number of times. 
+- The `CommandOnCooldown` exception can be handled using an error handler. I will discuss about making an error handler for slash commands later in the gist.
+
+
+# Handling errors for slash commands!
+
+---
+
+The Slash Commands exceptions can be handled by overwriting the `on_error` method of the `CommandTree`. The error handler takes two arguments. The first argument is the `Interaction` that took place when the error occurred and the second argument is the error that occurred when the slash commands was invoked. The error is an instance of [discord.app_commands.AppCommandError](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=appcommanderror#discord.app_commands.AppCommandError) which is a subclass of [DiscordException](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discordexception#discord.DiscordException).
+An example to creating an error handler for slash commands is as follows.
+
+```python
+from discord.ext import commands
+from discord import app_commands
+import discord
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync commands
+
+@bot.tree.command(name="ping")
+app_commands.checks.cooldown(1, 30)
+async def ping(interaction: discord.Interaction):
+    await interaction.response.send_message("pong!")
+
+async def on_tree_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
+    if isinstance(error, app_commands.CommandOnCooldown):
+        return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!")
+
+    elif isinstance(..., ...):
+        ...
+
+    else:
+        raise error
+
+bot.tree.on_error = on_tree_error
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+
+First we create a simple function named as `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here I have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. After creating the error handler function, we set the function as the error handler for the slash commands. Here, `bot.tree.on_error = on_tree_error` overwrites the default `on_error` method of the **CommandTree** class with our custom error handler which has been named as `on_tree_error` here.
+
+### Creating an error handler for a specific error!
+
+```python
+from discord.ext import commands
+from discord import app_commands
+import discord
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync commands
+
+@bot.tree.command(name="ping")
+app_commands.checks.cooldown(1, 30)
+async def ping(interaction: discord.Interaction):
+    await interaction.response.send_message("pong!")
+
+@ping.error
+async def ping_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
+    if isinstance(error, app_commands.CommandOnCooldown):
+        return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!")
+
+    elif isinstance(..., ...):
+        ...
+
+    else:
+        raise error
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+
+Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. Please do not call the `error` method.
\ No newline at end of file
-- 
cgit v1.2.3
From 1e59d608eb5f54b238aa55587a7bc9bfa32346d4 Mon Sep 17 00:00:00 2001
From: Robin <74519799+Robin5605@users.noreply.github.com>
Date: Tue, 28 Jun 2022 13:20:06 -0500
Subject: Fix EOF
Add a newline at the end of file
---
 .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
index 79d224a1..57423fa4 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
@@ -448,4 +448,4 @@ bot.run("token")
 
 __**EXPLANATION**__
 
-Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. Please do not call the `error` method.
\ No newline at end of file
+Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. Please do not call the `error` method.
-- 
cgit v1.2.3
From ce144ef99340339af732d41aa56714022f8a023f Mon Sep 17 00:00:00 2001
From: Robin5605 
Date: Tue, 28 Jun 2022 13:47:01 -0500
Subject: Fix trailing whitespaces
---
 .../guides/python-guides/discord-app-commands.md   | 56 +++++++++++-----------
 1 file changed, 28 insertions(+), 28 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
index 57423fa4..e6095252 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
@@ -56,12 +56,12 @@ import discord
 class SlashClient(discord.Client):
     def __init__(self) -> None:
         super().__init__(intents=discord.Intents.default())
-    
+
     async def setup_hook(self) -> None:
         #perform tasks
 
 '''Another way of creating a "setup_hook" is as follows'''
- 
+
 client = discord.Client(intents=discord.Intents.default())
 async def my_setup_hook() -> None:
     #perform tasks
@@ -83,7 +83,7 @@ class SlashClient(discord.Client):
     def __init__(self) -> None:
         super().__init__(intents=discord.Intents.default())
         self.tree = discord.app_commands.CommandTree(self)
-    
+
     async def setup_hook(self) -> None:
         self.tree.copy_global_to(guild=discord.Object(id=12345678900987654))
         await self.tree.sync()
@@ -100,11 +100,11 @@ client.run("token")
 
 __**EXPLANATION**__
 
-- `import discord` imports the **discord.py** package. 
-- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid. 
-- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commaands. 
-- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. 
-- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`. 
+- `import discord` imports the **discord.py** package.
+- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid.
+- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commaands.
+- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands.
+- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`.
 - Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker.
 - And the classic old `client.run("token")` is used to connect the client to the discord gateway.
 - Note that the `send_message` is a method of the `InteractionResponse` class and `interaction.response` in this case is an instance of the `InteractionResponse` object. The `send_message` method will not function if the response is not sent within 3 seconds of command invocation. I will discuss about how to handle this issue later following the gist.
@@ -117,7 +117,7 @@ import discord
 class SlashBot(commands.Bot):
     def __init__(self) -> None:
         super().__init__(command_prefix=".", intents=discord.Intents.default())
-        
+
     async def setup_hook(self) -> None:
         self.tree.copy_global_to(guild=discord.Object(id=12345678900987654))
         await self.tree.sync()
@@ -156,20 +156,20 @@ from discord import app_commands
 class MySlashCog(commands.Cog):
     def __init__(self, bot: commands.Bot) -> None:
         self.bot = bot
-    
+
     @app_commands.command(name="ping", description="...")
     async def _ping(self, interaction: discord.Interaction):
         await interaction.response.send_message("pong!")
-    
+
 class MySlashBot(commands.Bot):
     def __init__(self) -> None:
         super().__init__(command_prefix="!", intents=discord.Intents.default())
-    
+
     async def setup_hook(self) -> None:
         await self.add_cog(MySlashCog(self))
         await self.tree.copy_global_to(discord.Object(id=123456789098765432))
         await self.tree.sync()
-        
+
 bot = MySlashBot()
 
 bot.run("token")
@@ -207,20 +207,20 @@ class MySlashGroupCog(commands.Cog):
     @app_commands.command(name="ping", description="...")
     async def _ping(self, interaction: discord.) -> None:
         await interaction.response.send_message("pong!")
-    
+
     @group.command(name="command", description="...")
     async def _cmd(self, interaction: discord.Interaction) -> None:
         await interaction.response.send_message("uwu")
-    
+
 class MySlashBot(commands.Bot):
     def __init__(self) -> None:
         super().__init__(command_prefix="!", intents=discord.Intents.default())
-    
+
     async def setup_hook(self) -> None:
         await self.add_cog(MySlashGroupCog(self))
         await self.tree.copy_global_to(discord.Object(id=123456789098765432))
         await self.tree.sync()
-        
+
 bot = MySlashBot()
 
 bot.run("token")
@@ -246,20 +246,20 @@ class MySlashGroup(commands.GroupCog, name="uwu"):
     @app_commands.command(name="ping", description="...")
     async def _ping(self, interaction: discord.) -> None:
         await interaction.response.send_message("pong!")
-    
+
     @app_commands.command(name="command", description="...")
     async def _cmd(self, interaction: discord.Interaction) -> None:
         await interaction.response.send_message("uwu")
-    
+
 class MySlashBot(commands.Bot):
     def __init__(self) -> None:
         super().__init__(command_prefix="!", intents=discord.Intents.default())
-    
+
     async def setup_hook(self) -> None:
         await self.add_cog(MySlashGroup(self))
         await self.tree.copy_global_to(discord.Object(id=123456789098765432))
         await self.tree.sync()
-        
+
 bot = MySlashBot()
 
 bot.run("token")
@@ -271,7 +271,7 @@ __**EXPLANATION**__
 
 # Some common methods and features used for slash commands.
 
---- 
+---
 
 ### A common function used for slash commands is the `describe` function. This is used to add descriptions to the arguments of a slash command. The command function can decorated with this function. It goes by the following syntax as shown below.
 
@@ -353,13 +353,13 @@ import discord
 class Bot(commands.Bot):
     def __init__(self):
         super().__init__(command_prefix="uwu", intents=discord.Intents.all())
-        
-    
+
+
     async def setup_hook(self):
-        self.tree.copy_global_to(guild=discord.Object(id=12345678909876543)) 
+        self.tree.copy_global_to(guild=discord.Object(id=12345678909876543))
         await self.tree.sync()
-        
-    
+
+
 bot = Bot()
 
 @bot.tree.command(name="ping")
@@ -374,7 +374,7 @@ bot.run("token")
 
 __**EXPLANATION**__
 - The first argument the `cooldown` method takes is the amount of times the command can be run in a specific period of time.
-- The second argument it takes is the period of time in which the command can be run the specified number of times. 
+- The second argument it takes is the period of time in which the command can be run the specified number of times.
 - The `CommandOnCooldown` exception can be handled using an error handler. I will discuss about making an error handler for slash commands later in the gist.
 
 
-- 
cgit v1.2.3
From 9f275f199f4388c89fe8d600a7f102cbec4aa5c5 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Tue, 28 Jun 2022 22:06:42 +0200
Subject: Reorder frameworks after recommended use
---
 .../events/pages/code-jams/9/frameworks.html       | 66 ++++++++++++----------
 1 file changed, 36 insertions(+), 30 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 15e280aa..25eed595 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -16,86 +16,92 @@
         Please work with your team to choose a library that everyone can and want to develop with.
         If there is a library not listed below that you think should be here, you're welcome to discuss it with the Events Team over at the server.
     
-    
+
+    
         
             
-                
websockets
-                
websockets is a library for building WebSocket servers and clients in Python with a focus on correctness, simplicity, robustness, and performance.
-                    Built on top of asyncio, Python’s standard asynchronous I/O framework, it provides an elegant coroutine-based API.
+                
FastAPI
+                
FastAPI is a modern, fast (high-performance), web framework for building APIs with Python 3.6+ based on standard Python type hints.
                 
              
          
         
      
+
     
         
             
-                
Flask-SocketIO
-                
Flask-SocketIO gives Flask applications access to low latency bi-directional communications between the clients and the server.
+                
Starlette
+                
Starlette is a lightweight ASGI framework/toolkit, which is ideal for building async web services in Python.
                 
              
          
         
      
+
     
         
             
-                
Django Channels
-                
Channels is a project that takes Django and extends its abilities beyond HTTP - to handle WebSockets, chat protocols, IoT protocols, and more.
-                    It’s built on a Python specification called ASGI.
+                
websockets
+                
websockets is a library for building WebSocket servers and clients in Python with a focus on correctness, simplicity, robustness, and performance.
+                    Built on top of asyncio, Python’s standard asynchronous I/O framework, it provides an elegant coroutine-based API.
                 
              
          
         
      
+
     
         
             
-                
wsproto
-                
wsproto is a WebSocket protocol stack written to be as flexible as possible.
-                    To that end it is written in pure Python and performs no I/O of its own.
-                    Instead it relies on the user to provide a bridge between it and whichever I/O mechanism is in use, allowing it to be used in single-threaded, multi-threaded or event-driven code.
+                
Django Channels
+                
Channels is a project that takes Django and extends its abilities beyond HTTP - to handle WebSockets, chat protocols, IoT protocols, and more.
+                    It’s built on a Python specification called ASGI.
                 
              
          
         
      
+
     
         
             
-                
Starlette
-                
Starlette is a lightweight ASGI framework/toolkit, which is ideal for building async web services in Python.
+                
Flask-SocketIO
+                
Flask-SocketIO gives Flask applications access to low latency bi-directional communications between the clients and the server.
                 
              
          
         
      
-    
+
+    
         
             
-                
FastAPI
-                
FastAPI is a modern, fast (high-performance), web framework for building APIs with Python 3.6+ based on standard Python type hints.
+                
wsproto
+                
wsproto is a WebSocket protocol stack written to be as flexible as possible.
+                    To that end it is written in pure Python and performs no I/O of its own.
+                    Instead it relies on the user to provide a bridge between it and whichever I/O mechanism is in use, allowing it to be used in single-threaded, multi-threaded or event-driven code.
                 
              
          
         
      
 
-- 
cgit v1.2.3
From d4e9847673603b0081b971fb19b6502638842a3c Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Wed, 29 Jun 2022 00:42:21 +0200
Subject: Reword CJ9 frameworks explanations
---
 .../templates/events/pages/code-jams/9/frameworks.html       | 12 ++++--------
 1 file changed, 4 insertions(+), 8 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 25eed595..36a6604c 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -21,7 +21,7 @@
         
             
                 FastAPI
-                
FastAPI is a modern, fast (high-performance), web framework for building APIs with Python 3.6+ based on standard Python type hints.
+                
FastAPI is a modern web framework great for WebSockets based on standard Python type hints which provides great editor support.
                 
              
          
@@ -49,8 +49,7 @@
         
             
                 websockets
-                
websockets is a library for building WebSocket servers and clients in Python with a focus on correctness, simplicity, robustness, and performance.
-                    Built on top of asyncio, Python’s standard asynchronous I/O framework, it provides an elegant coroutine-based API.
+                
websockets is a library for building both WebSocket clients and servers with focus on simplicity and performance.
                 
              
          
@@ -64,8 +63,7 @@
         
             
                 Django Channels
-                
Channels is a project that takes Django and extends its abilities beyond HTTP - to handle WebSockets, chat protocols, IoT protocols, and more.
-                    It’s built on a Python specification called ASGI.
+                
Django Channels adds WebSocket-support to Django - built on ASGI like other web frameworks.
                 
              
          
@@ -93,9 +91,7 @@
         
             
                 wsproto
-                
wsproto is a WebSocket protocol stack written to be as flexible as possible.
-                    To that end it is written in pure Python and performs no I/O of its own.
-                    Instead it relies on the user to provide a bridge between it and whichever I/O mechanism is in use, allowing it to be used in single-threaded, multi-threaded or event-driven code.
+                
wsproto is a pure-Python WebSocket protocol stack written to be as flexible as possible by having the user build the bridge to the I/O.
                 
              
          
-- 
cgit v1.2.3
From ca2631dd16a0381356c02e2dc511b12285aacfd0 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Wed, 29 Jun 2022 23:50:28 +0200
Subject: Update CJ9 qualifier information
---
 pydis_site/templates/events/pages/code-jams/9/_index.html | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/_index.html b/pydis_site/templates/events/pages/code-jams/9/_index.html
index 6e458481..ec4af573 100644
--- a/pydis_site/templates/events/pages/code-jams/9/_index.html
+++ b/pydis_site/templates/events/pages/code-jams/9/_index.html
@@ -31,9 +31,11 @@
         Sunday, August 4 - Code Jam submissions are closed
     
     
-    The Qualifier isn't released yet, but to receive the most up-to-date information and to get notified
-        when the Qualifier is released you can join the server: discord.gg/python.
-
+    
+        Before being able to join the code jam, you must complete a qualifier which tests your knowledge in Python.
+        The qualifier can be found on our GitHub
+        and once completed you should submit your solution using the sign-up form.
+    
     
     
         The chosen technology/tech stack for this year is WebSockets.
-- 
cgit v1.2.3
From 4847f18d50158dcfbe8c9a84b54bcfe25e0b7452 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 30 Jun 2022 00:02:45 +0200
Subject: Update events index page about the CJ9 qualifier
---
 pydis_site/templates/events/index.html                  |  2 +-
 pydis_site/templates/events/pages/code-jams/_index.html | 11 +++--------
 2 files changed, 4 insertions(+), 9 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/index.html b/pydis_site/templates/events/index.html
index 62d62111..db3e32f7 100644
--- a/pydis_site/templates/events/index.html
+++ b/pydis_site/templates/events/index.html
@@ -10,7 +10,7 @@
     
         
         
-          The 
2022 Summer Code Jam qualifier will open June 29th. Check out the details 
here.
+          The 
2022 Summer Code Jam is currently underway and you can still enter! 
The qualifier is open until July 13; check out the details 
here.
         
 
         Every year we hold a community-wide Summer Code Jam. For this event, members of our community are assigned to teams to collaborate and create something amazing using a technology we picked for them. One such technology that was picked for the Summer 2021 Code Jam was text user interfaces (TUIs), where teams could pick from a pre-approved list of frameworks.
         To help fuel the creative process, we provide a specific theme, like Think Inside the Box or Early Internet. At the end of the Code Jam, the projects are judged by Python Discord server staff members and guest judges from the larger Python community. The judges will consider creativity, code quality, teamwork, and adherence to the theme.
diff --git a/pydis_site/templates/events/pages/code-jams/_index.html b/pydis_site/templates/events/pages/code-jams/_index.html
index 5e3cd930..74efcfaa 100644
--- a/pydis_site/templates/events/pages/code-jams/_index.html
+++ b/pydis_site/templates/events/pages/code-jams/_index.html
@@ -9,14 +9,9 @@
 
 {% block event_content %}
     
 
     
-- 
cgit v1.2.3
From 924489925a0eafecca67c041377f14a9ba632844 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 30 Jun 2022 00:07:28 +0200
Subject: Strike out passed dates
---
 pydis_site/templates/events/pages/code-jams/9/_index.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/_index.html b/pydis_site/templates/events/pages/code-jams/9/_index.html
index ec4af573..7c57b799 100644
--- a/pydis_site/templates/events/pages/code-jams/9/_index.html
+++ b/pydis_site/templates/events/pages/code-jams/9/_index.html
@@ -22,8 +22,8 @@
 
     
     
-        - Saturday, June 18 - Form to submit theme suggestions opens
 
-        - Wednesday, June 29 - The Qualifier is released
 
+        Saturday, June 18 - Form to submit theme suggestions opens 
+        Wednesday, June 29 - The Qualifier is released 
         - Wednesday, July 6 - Voting for the theme opens
 
         - Wednesday, July 13 - The Qualifier closes
 
         - Thursday, July 21 - Code Jam Begins
 
-- 
cgit v1.2.3
From 9792dbcf8ac13785b20b60fbf3d89a30c4d13fa4 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 30 Jun 2022 00:09:54 +0200
Subject: Add ASGI specification to list of approved CJ9 frameworks
---
 pydis_site/templates/events/pages/code-jams/9/frameworks.html | 8 ++++++++
 1 file changed, 8 insertions(+)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 36a6604c..63593ce4 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -87,6 +87,14 @@
         
 
     
 
+    
+        
+            
+                
ASGI
+                
ASGI is the specification your favourite frameworks are built on. This is not a framework in of itself, but listed here for completeness.
+            
+        
+
     
         
             
-- 
cgit v1.2.3
From 05cbea4a822d78d1b61caeaa5cf2dc47c85e819e Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 30 Jun 2022 00:18:57 +0200
Subject: Add ASGI-card footer with link to specification
---
 pydis_site/templates/events/pages/code-jams/9/frameworks.html | 4 ++++
 1 file changed, 4 insertions(+)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 63593ce4..7e5f4050 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -94,6 +94,10 @@
                 ASGI is the specification your favourite frameworks are built on. This is not a framework in of itself, but listed here for completeness.
              
          
+        
+    
 
 
     
         
-- 
cgit v1.2.3
From 1edf8686e00f8a3f81790da6e17717ec2816fe78 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 30 Jun 2022 00:19:28 +0200
Subject: Remove italics from CJ9 framework cards
---
 .../templates/events/pages/code-jams/9/frameworks.html    | 15 +++++++--------
 1 file changed, 7 insertions(+), 8 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 7e5f4050..67e57072 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -21,8 +21,7 @@
         
             
                 FastAPI
-                
FastAPI is a modern web framework great for WebSockets based on standard Python type hints which provides great editor support.
-                
+                
FastAPI is a modern web framework great for WebSockets based on standard Python type hints which provides great editor support.
              
          
         
         
     
 
-- 
cgit v1.2.3
From e5ed03d810048742901892922bc413c8ff86c85a Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 30 Jun 2022 00:23:40 +0200
Subject: Fix missing space after FastAPI link card
---
 pydis_site/templates/events/pages/code-jams/9/frameworks.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 1d0ccf11..916f249b 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -17,7 +17,7 @@
         If there is a library not listed below that you think should be here, you're welcome to discuss it with the Events Team over at the server.
     
 
-    
+    
         
             
                 FastAPI
-- 
cgit v1.2.3
From a6ad20d9fdb133b605a6d2d6f6513f39a8c29356 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 30 Jun 2022 00:42:53 +0200
Subject: Fix ASGI link class name
---
 pydis_site/templates/events/pages/code-jams/9/frameworks.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 916f249b..a154edc7 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -94,7 +94,7 @@
              
          
         
      
 
-- 
cgit v1.2.3
From a2de9e9b5eec9efd6cce2578a6b2c3bb4b818e4a Mon Sep 17 00:00:00 2001
From: kosayoda 
Date: Wed, 29 Jun 2022 20:14:43 -0400
Subject: Update front page banner.
---
 pydis_site/templates/home/index.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/home/index.html b/pydis_site/templates/home/index.html
index fcbb87e4..cdbac830 100644
--- a/pydis_site/templates/home/index.html
+++ b/pydis_site/templates/home/index.html
@@ -12,7 +12,7 @@
   
   
 
@@ -48,7 +48,7 @@
           {# Code Jam Banner #}
           
          
-- 
cgit v1.2.3
From d4f717ec186ffeedf7bdeb4991868160f1540b83 Mon Sep 17 00:00:00 2001
From: Chris Lovering 
Date: Thu, 30 Jun 2022 10:48:33 +0100
Subject: Remove embed validators for deleted messages
These caused more harm than they were worth, as every time Discord updated a behaviour of an embed we would get errors and need ot update the validation. Instead we should just accept whatever discord gives us as correct
---
 .../api/migrations/0083_remove_embed_validation.py |  19 ++
 pydis_site/apps/api/models/bot/message.py          |   5 +-
 pydis_site/apps/api/models/utils.py                | 172 ----------------
 pydis_site/apps/api/tests/test_validators.py       | 229 ---------------------
 4 files changed, 20 insertions(+), 405 deletions(-)
 create mode 100644 pydis_site/apps/api/migrations/0083_remove_embed_validation.py
 delete mode 100644 pydis_site/apps/api/models/utils.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0083_remove_embed_validation.py b/pydis_site/apps/api/migrations/0083_remove_embed_validation.py
new file mode 100644
index 00000000..e835bb66
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0083_remove_embed_validation.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1.14 on 2022-06-30 09:41
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0082_otn_allow_big_solidus'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='deletedmessage',
+            name='embeds',
+            field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(), blank=True, help_text='Embeds attached to this message.', size=None),
+        ),
+    ]
diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py
index bab3368d..bfa54721 100644
--- a/pydis_site/apps/api/models/bot/message.py
+++ b/pydis_site/apps/api/models/bot/message.py
@@ -7,7 +7,6 @@ from django.utils import timezone
 
 from pydis_site.apps.api.models.bot.user import User
 from pydis_site.apps.api.models.mixins import ModelReprMixin
-from pydis_site.apps.api.models.utils import validate_embed
 
 
 class Message(ModelReprMixin, models.Model):
@@ -48,9 +47,7 @@ class Message(ModelReprMixin, models.Model):
         blank=True
     )
     embeds = pgfields.ArrayField(
-        models.JSONField(
-            validators=(validate_embed,)
-        ),
+        models.JSONField(),
         blank=True,
         help_text="Embeds attached to this message."
     )
diff --git a/pydis_site/apps/api/models/utils.py b/pydis_site/apps/api/models/utils.py
deleted file mode 100644
index 859394d2..00000000
--- a/pydis_site/apps/api/models/utils.py
+++ /dev/null
@@ -1,172 +0,0 @@
-from collections.abc import Mapping
-from typing import Any, Dict
-
-from django.core.exceptions import ValidationError
-from django.core.validators import MaxLengthValidator, MinLengthValidator
-
-
-def is_bool_validator(value: Any) -> None:
-    """Validates if a given value is of type bool."""
-    if not isinstance(value, bool):
-        raise ValidationError(f"This field must be of type bool, not {type(value)}.")
-
-
-def validate_embed_fields(fields: dict) -> None:
-    """Raises a ValidationError if any of the given embed fields is invalid."""
-    field_validators = {
-        'name': (MaxLengthValidator(limit_value=256),),
-        'value': (MaxLengthValidator(limit_value=1024),),
-        'inline': (is_bool_validator,),
-    }
-
-    required_fields = ('name', 'value')
-
-    for field in fields:
-        if not isinstance(field, Mapping):
-            raise ValidationError("Embed fields must be a mapping.")
-
-        if not all(required_field in field for required_field in required_fields):
-            raise ValidationError(
-                f"Embed fields must contain the following fields: {', '.join(required_fields)}."
-            )
-
-        for field_name, value in field.items():
-            if field_name not in field_validators:
-                raise ValidationError(f"Unknown embed field field: {field_name!r}.")
-
-            for validator in field_validators[field_name]:
-                validator(value)
-
-
-def validate_embed_footer(footer: Dict[str, str]) -> None:
-    """Raises a ValidationError if the given footer is invalid."""
-    field_validators = {
-        'text': (
-            MinLengthValidator(
-                limit_value=1,
-                message="Footer text must not be empty."
-            ),
-            MaxLengthValidator(limit_value=2048)
-        ),
-        'icon_url': (),
-        'proxy_icon_url': ()
-    }
-
-    if not isinstance(footer, Mapping):
-        raise ValidationError("Embed footer must be a mapping.")
-
-    for field_name, value in footer.items():
-        if field_name not in field_validators:
-            raise ValidationError(f"Unknown embed footer field: {field_name!r}.")
-
-        for validator in field_validators[field_name]:
-            validator(value)
-
-
-def validate_embed_author(author: Any) -> None:
-    """Raises a ValidationError if the given author is invalid."""
-    field_validators = {
-        'name': (
-            MinLengthValidator(
-                limit_value=1,
-                message="Embed author name must not be empty."
-            ),
-            MaxLengthValidator(limit_value=256)
-        ),
-        'url': (),
-        'icon_url': (),
-        'proxy_icon_url': ()
-    }
-
-    if not isinstance(author, Mapping):
-        raise ValidationError("Embed author must be a mapping.")
-
-    for field_name, value in author.items():
-        if field_name not in field_validators:
-            raise ValidationError(f"Unknown embed author field: {field_name!r}.")
-
-        for validator in field_validators[field_name]:
-            validator(value)
-
-
-def validate_embed(embed: Any) -> None:
-    """
-    Validate a JSON document containing an embed as possible to send on Discord.
-
-    This attempts to rebuild the validation used by Discord
-    as well as possible by checking for various embed limits so we can
-    ensure that any embed we store here will also be accepted as a
-    valid embed by the Discord API.
-
-    Using this directly is possible, although not intended - you usually
-    stick this onto the `validators` keyword argument of model fields.
-
-    Example:
-
-        >>> from django.db import models
-        >>> from pydis_site.apps.api.models.utils import validate_embed
-        >>> class MyMessage(models.Model):
-        ...     embed = models.JSONField(
-        ...         validators=(
-        ...             validate_embed,
-        ...         )
-        ...     )
-        ...     # ...
-        ...
-
-    Args:
-        embed (Any):
-            A dictionary describing the contents of this embed.
-            See the official documentation for a full reference
-            of accepted keys by this dictionary:
-                https://discordapp.com/developers/docs/resources/channel#embed-object
-
-    Raises:
-        ValidationError:
-            In case the given embed is deemed invalid, a `ValidationError`
-            is raised which in turn will allow Django to display errors
-            as appropriate.
-    """
-    all_keys = {
-        'title', 'type', 'description', 'url', 'timestamp',
-        'color', 'footer', 'image', 'thumbnail', 'video',
-        'provider', 'author', 'fields'
-    }
-    one_required_of = {'description', 'fields', 'image', 'title', 'video'}
-    field_validators = {
-        'title': (
-            MinLengthValidator(
-                limit_value=1,
-                message="Embed title must not be empty."
-            ),
-            MaxLengthValidator(limit_value=256)
-        ),
-        'description': (MaxLengthValidator(limit_value=4096),),
-        'fields': (
-            MaxLengthValidator(limit_value=25),
-            validate_embed_fields
-        ),
-        'footer': (validate_embed_footer,),
-        'author': (validate_embed_author,)
-    }
-
-    if not embed:
-        raise ValidationError("Tag embed must not be empty.")
-
-    elif not isinstance(embed, Mapping):
-        raise ValidationError("Tag embed must be a mapping.")
-
-    elif not any(field in embed for field in one_required_of):
-        raise ValidationError(f"Tag embed must contain one of the fields {one_required_of}.")
-
-    for required_key in one_required_of:
-        if required_key in embed and not embed[required_key]:
-            raise ValidationError(f"Key {required_key!r} must not be empty.")
-
-    for field_name, value in embed.items():
-        if field_name not in all_keys:
-            raise ValidationError(f"Unknown field name: {field_name!r}")
-
-        if field_name in field_validators:
-            for validator in field_validators[field_name]:
-                validator(value)
diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py
index 551cc2aa..8c46fcbc 100644
--- a/pydis_site/apps/api/tests/test_validators.py
+++ b/pydis_site/apps/api/tests/test_validators.py
@@ -5,7 +5,6 @@ from django.test import TestCase
 
 from ..models.bot.bot_setting import validate_bot_setting_name
 from ..models.bot.offensive_message import future_date_validator
-from ..models.utils import validate_embed
 
 
 REQUIRED_KEYS = (
@@ -22,234 +21,6 @@ class BotSettingValidatorTests(TestCase):
             validate_bot_setting_name('bad name')
 
 
-class TagEmbedValidatorTests(TestCase):
-    def test_rejects_non_mapping(self):
-        with self.assertRaises(ValidationError):
-            validate_embed('non-empty non-mapping')
-
-    def test_rejects_missing_required_keys(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'unknown': "key"
-            })
-
-    def test_rejects_one_correct_one_incorrect(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'provider': "??",
-                'title': ""
-            })
-
-    def test_rejects_empty_required_key(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'title': ''
-            })
-
-    def test_rejects_list_as_embed(self):
-        with self.assertRaises(ValidationError):
-            validate_embed([])
-
-    def test_rejects_required_keys_and_unknown_keys(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'title': "the duck walked up to the lemonade stand",
-                'and': "he said to the man running the stand"
-            })
-
-    def test_rejects_too_long_title(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'title': 'a' * 257
-            })
-
-    def test_rejects_too_many_fields(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'fields': [{} for _ in range(26)]
-            })
-
-    def test_rejects_too_long_description(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'description': 'd' * 4097
-            })
-
-    def test_allows_valid_embed(self):
-        validate_embed({
-            'title': "My embed",
-            'description': "look at my embed, my embed is amazing"
-        })
-
-    def test_allows_unvalidated_fields(self):
-        validate_embed({
-            'title': "My embed",
-            'provider': "what am I??"
-        })
-
-    def test_rejects_fields_as_list_of_non_mappings(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'fields': ['abc']
-            })
-
-    def test_rejects_fields_with_unknown_fields(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'fields': [
-                    {
-                        'what': "is this field"
-                    }
-                ]
-            })
-
-    def test_rejects_fields_with_too_long_name(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'fields': [
-                    {
-                        'name': "a" * 257
-                    }
-                ]
-            })
-
-    def test_rejects_one_correct_one_incorrect_field(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'fields': [
-                    {
-                        'name': "Totally valid",
-                        'value': "LOOK AT ME"
-                    },
-                    {
-                        'name': "Totally valid",
-                        'value': "LOOK AT ME",
-                        'oh': "what is this key?"
-                    }
-                ]
-            })
-
-    def test_rejects_missing_required_field_field(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'fields': [
-                    {
-                        'name': "Totally valid",
-                        'inline': True,
-                    }
-                ]
-            })
-
-    def test_rejects_invalid_inline_field_field(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'fields': [
-                    {
-                        'name': "Totally valid",
-                        'value': "LOOK AT ME",
-                        'inline': "Totally not a boolean",
-                    }
-                ]
-            })
-
-    def test_allows_valid_fields(self):
-        validate_embed({
-            'fields': [
-                {
-                    'name': "valid",
-                    'value': "field",
-                },
-                {
-                    'name': "valid",
-                    'value': "field",
-                    'inline': False,
-                },
-                {
-                    'name': "valid",
-                    'value': "field",
-                    'inline': True,
-                },
-            ]
-        })
-
-    def test_rejects_footer_as_non_mapping(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'title': "whatever",
-                'footer': []
-            })
-
-    def test_rejects_footer_with_unknown_fields(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'title': "whatever",
-                'footer': {
-                    'duck': "quack"
-                }
-            })
-
-    def test_rejects_footer_with_empty_text(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'title': "whatever",
-                'footer': {
-                    'text': ""
-                }
-            })
-
-    def test_allows_footer_with_proper_values(self):
-        validate_embed({
-            'title': "whatever",
-            'footer': {
-                'text': "django good"
-            }
-        })
-
-    def test_rejects_author_as_non_mapping(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'title': "whatever",
-                'author': []
-            })
-
-    def test_rejects_author_with_unknown_field(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'title': "whatever",
-                'author': {
-                    'field': "that is unknown"
-                }
-            })
-
-    def test_rejects_author_with_empty_name(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'title': "whatever",
-                'author': {
-                    'name': ""
-                }
-            })
-
-    def test_rejects_author_with_one_correct_one_incorrect(self):
-        with self.assertRaises(ValidationError):
-            validate_embed({
-                'title': "whatever",
-                'author': {
-                    # Relies on "dictionary insertion order remembering" (D.I.O.R.) behaviour
-                    'url': "bobswebsite.com",
-                    'name': ""
-                }
-            })
-
-    def test_allows_author_with_proper_values(self):
-        validate_embed({
-            'title': "whatever",
-            'author': {
-                'name': "Bob"
-            }
-        })
-
-
 class OffensiveMessageValidatorsTests(TestCase):
     def test_accepts_future_date(self):
         future_date_validator(datetime(3000, 1, 1, tzinfo=timezone.utc))
-- 
cgit v1.2.3
From 084357dbcc48445262fe078e7cb035d46be02e48 Mon Sep 17 00:00:00 2001
From: Chris Lovering 
Date: Thu, 30 Jun 2022 10:49:24 +0100
Subject: Remove embed validators from old migrations
Since the util file has been deleted, these migrations were referencing a missing file
---
 pydis_site/apps/api/migrations/0019_deletedmessage.py             | 2 +-
 pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py | 3 +--
 pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py      | 3 +--
 3 files changed, 3 insertions(+), 5 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/migrations/0019_deletedmessage.py b/pydis_site/apps/api/migrations/0019_deletedmessage.py
index 6b848d64..25d04434 100644
--- a/pydis_site/apps/api/migrations/0019_deletedmessage.py
+++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
                 ('id', models.BigIntegerField(help_text='The message ID as taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Message IDs cannot be negative.')])),
                 ('channel_id', models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])),
                 ('content', models.CharField(help_text='The content of this message, taken from Discord.', max_length=2000)),
-                ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), help_text='Embeds attached to this message.', size=None)),
+                ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[]), help_text='Embeds attached to this message.', size=None)),
                 ('author', models.ForeignKey(help_text='The author of this message.', on_delete=django.db.models.deletion.CASCADE, to='api.User')),
                 ('deletion_context', models.ForeignKey(help_text='The deletion context this message is part of.', on_delete=django.db.models.deletion.CASCADE, to='api.MessageDeletionContext')),
             ],
diff --git a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py
index 124c6a57..622f21d1 100644
--- a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py
+++ b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py
@@ -3,7 +3,6 @@
 import django.contrib.postgres.fields
 import django.contrib.postgres.fields.jsonb
 from django.db import migrations
-import pydis_site.apps.api.models.utils
 
 
 class Migration(migrations.Migration):
@@ -16,6 +15,6 @@ class Migration(migrations.Migration):
         migrations.AlterField(
             model_name='deletedmessage',
             name='embeds',
-            field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), blank=True, help_text='Embeds attached to this message.', size=None),
+            field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[]), blank=True, help_text='Embeds attached to this message.', size=None),
         ),
     ]
diff --git a/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py b/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py
index 9e8f2fb9..95ef5850 100644
--- a/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py
+++ b/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py
@@ -2,7 +2,6 @@
 
 import django.contrib.postgres.fields
 from django.db import migrations, models
-import pydis_site.apps.api.models.utils
 
 
 class Migration(migrations.Migration):
@@ -20,6 +19,6 @@ class Migration(migrations.Migration):
         migrations.AlterField(
             model_name='deletedmessage',
             name='embeds',
-            field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), blank=True, help_text='Embeds attached to this message.', size=None),
+            field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(validators=[]), blank=True, help_text='Embeds attached to this message.', size=None),
         ),
     ]
-- 
cgit v1.2.3
From e264eb88f8e7931c83c0ca6843d6389a4e026e38 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 30 Jun 2022 20:04:42 +0200
Subject: Fix missing "p" inside of element
---
 pydis_site/templates/events/pages/code-jams/9/frameworks.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index a154edc7..91007197 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -62,7 +62,7 @@
         
             
                 Django Channels
-                <>Django Channels adds WebSocket-support to Django - built on ASGI like other web frameworks.
+                
Django Channels adds WebSocket-support to Django - built on ASGI like other web frameworks.
                 
              
          
-- 
cgit v1.2.3
From b6ac1f2f7c402411a29f1cfee10d79abb7c001d0 Mon Sep 17 00:00:00 2001
From: Ash <92868529+Ash-02014@users.noreply.github.com>
Date: Fri, 1 Jul 2022 15:59:16 +0100
Subject: Update
 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com>
---
 .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
index e6095252..431ab095 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
@@ -102,7 +102,7 @@ __**EXPLANATION**__
 
 - `import discord` imports the **discord.py** package.
 - `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid.
-- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commaands.
+- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commands, and binds it to the `discord.Client` subclass instance, so wherever you have access to it, you will also have access to the command tree.
 - Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands.
 - Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`.
 - Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker.
-- 
cgit v1.2.3
From b237e81890836343295bd84b80ff5b34ef05ab92 Mon Sep 17 00:00:00 2001
From: Ash <92868529+Ash-02014@users.noreply.github.com>
Date: Fri, 1 Jul 2022 15:59:26 +0100
Subject: Update
 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com>
---
 .../resources/guides/python-guides/discord-app-commands.md     | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
index 431ab095..1a6e2453 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
@@ -6,7 +6,15 @@ description: A simple guide to creating slash commands within discord.py!
 
 ---
 
-Upon resumption of the most popular discord API wrapper library for python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with addition of features to the library. Some additions to the library are -> Buttons support, Select Menus Support, Forms (AKA Modals), Slash Commands (AKA Application Commands) and a bunch of more handy features! All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py gist regarding resumption can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6).
+Upon resumption of the most popular discord API wrapper library for python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with addition of features to the library. Some additions to the library include: 
+
+- Buttons support
+- Select Menus support
+- Forms (AKA Modals)
+- Slash commands (AKA Application Commands)
+...and a bunch more handy features!
+
+All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py gist regarding resumption can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6).
 
 # Why this gist?
 
-- 
cgit v1.2.3
From 823a6d2e3ebe7667b6bb2e2cc49635b4684cefcc Mon Sep 17 00:00:00 2001
From: Ash <92868529+Ash-02014@users.noreply.github.com>
Date: Fri, 1 Jul 2022 16:00:51 +0100
Subject: Update
 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
A good point indeed
Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com>
---
 .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
index 1a6e2453..d5f204b0 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
@@ -111,7 +111,7 @@ __**EXPLANATION**__
 - `import discord` imports the **discord.py** package.
 - `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid.
 - Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commands, and binds it to the `discord.Client` subclass instance, so wherever you have access to it, you will also have access to the command tree.
-- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands.
+- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. **Without calling this method, your changes will only be saved locally and will NOT show up on Discord!**
 - Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`.
 - Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker.
 - And the classic old `client.run("token")` is used to connect the client to the discord gateway.
-- 
cgit v1.2.3
From 2a8b2f4b764c83584972224ad2cf8c625e6712e1 Mon Sep 17 00:00:00 2001
From: Ash <92868529+Ash-02014@users.noreply.github.com>
Date: Fri, 1 Jul 2022 16:01:33 +0100
Subject: Update
 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com>
---
 .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
index d5f204b0..7d7239e2 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
@@ -133,7 +133,7 @@ class SlashBot(commands.Bot):
 bot = SlashBot()
 
 @bot.tree.command(name="ping", description="...")
-async def _ping(interaction: discord.Interaction) -> None:
+async def ping(interaction: discord.Interaction) -> None:
     await interaction.response.send_message("pong")
 
 bot.run("token")
-- 
cgit v1.2.3
From 7e5fe0bab6e2b97d2fc4e855d05573ca02801caf Mon Sep 17 00:00:00 2001
From: Ash <92868529+Ash-02014@users.noreply.github.com>
Date: Fri, 1 Jul 2022 16:03:16 +0100
Subject: Update
 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com>
---
 .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
index 7d7239e2..1f5a0ff3 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
@@ -166,7 +166,7 @@ class MySlashCog(commands.Cog):
         self.bot = bot
 
     @app_commands.command(name="ping", description="...")
-    async def _ping(self, interaction: discord.Interaction):
+    async def ping(self, interaction: discord.Interaction):
         await interaction.response.send_message("pong!")
 
 class MySlashBot(commands.Bot):
-- 
cgit v1.2.3
From fff37e8bfd0f3ace81235a00b0d343cd8ba21383 Mon Sep 17 00:00:00 2001
From: Ash <92868529+Ash-02014@users.noreply.github.com>
Date: Fri, 1 Jul 2022 16:03:36 +0100
Subject: Update
 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
quite alright
Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com>
---
 .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
index 1f5a0ff3..65c0b025 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
@@ -410,7 +410,7 @@ async def on_tree_error(interaction: discord.Interaction, error: app_commands.Ap
     if isinstance(error, app_commands.CommandOnCooldown):
         return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!")
 
-    elif isinstance(..., ...):
+    elif isinstance(error, ...):
         ...
 
     else:
-- 
cgit v1.2.3
From ca3cb63131839258cdec5d28317a90193a15f082 Mon Sep 17 00:00:00 2001
From: Ash <92868529+Ash-02014@users.noreply.github.com>
Date: Fri, 1 Jul 2022 16:03:57 +0100
Subject: Update
 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com>
---
 .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
index 65c0b025..02a56ba6 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
@@ -436,7 +436,7 @@ bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
 #sync commands
 
 @bot.tree.command(name="ping")
-app_commands.checks.cooldown(1, 30)
+@app_commands.checks.cooldown(1, 30)
 async def ping(interaction: discord.Interaction):
     await interaction.response.send_message("pong!")
 
-- 
cgit v1.2.3
From 9344c927c4e4deba148202f23b7803c775d698a6 Mon Sep 17 00:00:00 2001
From: Ash <92868529+Ash-02014@users.noreply.github.com>
Date: Fri, 1 Jul 2022 16:05:07 +0100
Subject: Update
 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com>
---
 .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
index 02a56ba6..917c63a1 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
@@ -445,7 +445,7 @@ async def ping_error(interaction: discord.Interaction, error: app_commands.AppCo
     if isinstance(error, app_commands.CommandOnCooldown):
         return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!")
 
-    elif isinstance(..., ...):
+    elif isinstance(error, ...):
         ...
 
     else:
-- 
cgit v1.2.3
From 7475676fddb5b815e8ff24de3b5d094cda66439e Mon Sep 17 00:00:00 2001
From: Blue-Puddle 
Date: Fri, 1 Jul 2022 16:15:16 +0100
Subject: add app_commands.md
---
 .../resources/guides/python-guides/app_commands.md | 448 ++++++++++++++++++++
 .../guides/python-guides/discord-app-commands.md   | 459 ---------------------
 2 files changed, 448 insertions(+), 459 deletions(-)
 create mode 100644 pydis_site/apps/content/resources/guides/python-guides/app_commands.md
 delete mode 100644 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/app_commands.md b/pydis_site/apps/content/resources/guides/python-guides/app_commands.md
new file mode 100644
index 00000000..d97b849a
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/app_commands.md
@@ -0,0 +1,448 @@
+# DISCORD.PY RESUMATION CHANGES
+
+---
+
+Upon resumation of the most popular discord API wrapper library for python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with addition of features to the library. Some additions to the library are -> Buttons support, Select Menus Support, Forms (AKA Modals), Slash Commands (AKA Application Commands) and a bunch of more handy features! All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py gist regarding resumation can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6).
+
+# Why this gist?
+
+---
+
+This Gist is being created as an update to slash commands (app commands) with explanation and examples. This Gist mainly focuses on **SLASH COMMANDS** for discord.py 2.0 (and above)!
+
+# What Are Slash Commands?
+
+---
+
+Slash Commands are the exciting way to build and interact with bots on Discord. With Slash Commands, all you have to do is type / and you're ready to use your favourite bot. You can easily see all the commands a bot has, and validation and error handling help you get the command right the first time.
+
+# Install the latest version for discord.py
+
+---
+To use slash commands with discord.py. The latest up-to-date version has to be installed. Make sure that the version is 2.0 or above!
+And make sure to uninstall any third party libraries that support slash commands for discord.py (if any) as a few of them [monkey patch](https://en.wikipedia.org/wiki/Monkey_patch#:~:text=A%20monkey%20patch%20is%20a,running%20instance%20of%20the%20program) discord.py!
+
+The latest and up-to-date usable discord.py version can be installed using `pip install -U git+https://github.com/Rapptz/discord.py`.
+
+If you get an error such as: `'git' is not recognized...`. [Install git](https://git-scm.com/downloads) for your platform. Go through the required steps for installing git and make sure to enable the `add to path` option while in the installation wizard. After installing git you can run `pip install -U git+https://github.com/Rapptz/discord.py` again to install discord.py 2.0.
+**BEFORE MIGRATING TO DISCORD.PY 2.0, PLEASE READ THE CONSEQUENCES OF THE UPDATE [HERE](https://discordpy.readthedocs.io/en/latest/migrating.html)**.
+
+# Basic Structure for Discord.py Slash Commands!
+
+---
+
+### Note that Slash Commands in discord.py are also referred to as **Application Commmands** and **App Commands** and every *interaction* is a *webhook*.
+Slash commands in discord.py are held by a container, [CommandTree](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=commandtree#discord.app_commands.CommandTree). A command tree is required to create slash commands in discord.py. This command tree provides a `command` method which decorates an asynchronous function indicating to discord.py that the decorated function is intended to be a slash command. This asynchronous function expects a default argument which acts as the interaction which took place that invoked the slash command. This default argument is an instance of the **Interaction** class from discord.py. Further up, the command logic takes over the behaviour of the slash command.
+
+# Fundamentals for this gist!
+
+---
+
+
+The fundamental for this gist will remain to be the `setup_hook`. The `setup_hook` is a special asynchronous method of the Client and Bot class which can be overwritten to perform numerous tasks. This method is safe to use as it is always triggered before any events are dispatched, i.e. this method is triggered before the *IDENTIFY* payload is sent to the discord gateway.
+Note that methods of the Bot class such as `change_presence` will not work in setup_hook as the current application does not have an active connection to the gateway at this point.
+
+__**FOLLOWING IS THE EXAMPLE OF HOW A `SETUP_HOOK` FUNCTION CAN BE DEFINED**__
+
+```python
+import discord
+
+'''This is one way of creating a "setup_hook" method'''
+
+class SlashClient(discord.Client):
+    def __init__(self) -> None:
+        super().__init__(intents=discord.Intents.default())
+    
+    async def setup_hook(self) -> None:
+        #perform tasks
+
+'''Another way of creating a "setup_hook" is as follows'''
+ 
+client = discord.Client(intents=discord.Intents.default())
+async def my_setup_hook() -> None:
+    #perform tasks
+
+client.setup_hook = my_setup_hook
+```
+
+# Basic Slash Command application using discord.py.
+
+#### The `CommandTree` class resides within the `app_commands` of discord.py package.
+---
+
+## Slash Command Application with a Client
+
+```python
+import discord
+
+class SlashClient(discord.Client):
+    def __init__(self) -> None:
+        super().__init__(intents=discord.Intents.default())
+        self.tree = discord.app_commands.CommandTree(self)
+    
+    async def setup_hook(self) -> None:
+        self.tree.copy_global_to(guild=discord.Object(id=12345678900987654))
+        await self.tree.sync()
+
+client = SlashClient()
+
+@client.tree.command(name="ping", description="...")
+async def _ping(interaction: discord.Interaction) -> None:
+    await interaction.response.send_message("pong")
+
+client.run("token")
+```
+
+
+__**EXPLANATION**__
+
+- `import discord` imports the **discord.py** package. 
+- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid. 
+- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commaands. 
+- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. 
+- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`. 
+- Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker.
+- And the classic old `client.run("token")` is used to connect the client to the discord gateway.
+- Note that the `send_message` is a method of the `InteractionResponse` class and `interaction.response` in this case is an instance of the `InteractionResponse` object. The `send_message` method will not function if the response is not sent within 3 seconds of command invocation. I will discuss about how to handle this issue later following the gist.
+
+## Slash Command Application with a Bot
+
+```python
+import discord
+
+class SlashBot(commands.Bot):
+    def __init__(self) -> None:
+        super().__init__(command_prefix=".", intents=discord.Intents.default())
+        
+    async def setup_hook(self) -> None:
+        self.tree.copy_global_to(guild=discord.Object(id=12345678900987654))
+        await self.tree.sync()
+
+bot = SlashBot()
+
+@bot.tree.command(name="ping", description="...")
+async def _ping(interaction: discord.Interaction) -> None:
+    await interaction.response.send_message("pong")
+
+bot.run("token")
+```
+
+The above example shows a basic slash commands within discord.py using the Bot class.
+
+__**EXPLANATION**__
+
+Most of the explanation is the same as the prior example which featured `SlashClient` which was a subclass of **discord.Client**. Though some minor changes are discussed below.
+
+- The `SlashBot` class now subclasses `discord.ext.commands.Bot` following the passing in of the required arguments to its `__init__` method.
+- `discord.ext.commands.Bot` already consists of an instance of the `CommandTree` class which can be accessed using the `tree` property.
+
+# Slash Commands within a Cog!
+
+---
+
+A cog is a collection of commands, listeners, and optional state to help group commands together. More information on them can be found on the [Cogs](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs) page.
+
+## An Example to using cogs with discord.py for slash commands!
+
+```python
+import discord
+from discord.ext import commands
+from discord import app_commands
+
+class MySlashCog(commands.Cog):
+    def __init__(self, bot: commands.Bot) -> None:
+        self.bot = bot
+    
+    @app_commands.command(name="ping", description="...")
+    async def _ping(self, interaction: discord.Interaction):
+        await interaction.response.send_message("pong!")
+    
+class MySlashBot(commands.Bot):
+    def __init__(self) -> None:
+        super().__init__(command_prefix="!", intents=discord.Intents.default())
+    
+    async def setup_hook(self) -> None:
+        await self.add_cog(MySlashCog(self))
+        await self.tree.copy_global_to(discord.Object(id=123456789098765432))
+        await self.tree.sync()
+        
+bot = MySlashBot()
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+
+- Firstly, `import discord` imports the **discord.py** package. `from discord import app_commands` imports the `app_commands` directory from the **discord.py** root directory. `from discord.ext import commands` imports the commands extension.
+- Further up, `class MySlashCog(commands.Cog)` is a class subclassing the `Cog` class. You can read more about this [here](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs).
+- `def __init__(self, bot: commands.Bot): self.bot = bot` is the constructor method of the class that is always run when the class is instantiated and that is why we pass in a **Bot** object whenever we create an instance of the cog class.
+- Following up is the `@app_commands.command(name="ping", description="...")` decorator. This decorator basically functions the same as a `bot.tree.command` but since the cog currently does not have a **bot**, the `app_commands.command` decorator is used instead. The next two lines follow the same structure for slash commands with **self** added as the first parameter to the function as it is a method of a class.
+- The next up lines are mostly the same.
+- Talking about the first line inside the `setup_hook` is the `add_cog` method of the **Bot** class. And since **self** acts as the **instance** of the current class, we use **self** to use the `add_cog` method of the **Bot** class as we are inside a subclassed class of the **Bot** class. Then we pass in **self** to the `add_cog` method as the `__init__` function of the **MySlashCog** cog accepts a `Bot` object.
+- After that we instantiate the `MySlashBot` class and run the bot using the **run** method which executes our setup_hook function and our commands get loaded and synced. The bot is now ready to use!
+
+# An Example to using groups with discord.py for slash commands!
+
+---
+
+## An example with optional group!
+
+```python
+import discord
+from discord.ext import commands
+from discord import app_commands
+
+class MySlashGroupCog(commands.Cog):
+    def __init__(self, bot: commands.Bot) -> None:
+        self.bot = bot
+
+    #--------------------------------------------------------
+    group = app_commands.Group(name="uwu", description="...")
+    #--------------------------------------------------------
+
+    @app_commands.command(name="ping", description="...")
+    async def _ping(self, interaction: discord.) -> None:
+        await interaction.response.send_message("pong!")
+    
+    @group.command(name="command", description="...")
+    async def _cmd(self, interaction: discord.Interaction) -> None:
+        await interaction.response.send_message("uwu")
+    
+class MySlashBot(commands.Bot):
+    def __init__(self) -> None:
+        super().__init__(command_prefix="!", intents=discord.Intents.default())
+    
+    async def setup_hook(self) -> None:
+        await self.add_cog(MySlashGroupCog(self))
+        await self.tree.copy_global_to(discord.Object(id=123456789098765432))
+        await self.tree.sync()
+        
+bot = MySlashBot()
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+- The only difference used here is `group = app_commands.Group(name="uwu", description="...")` and `group.command`. `app_commands.Group` is used to initiate a group while `group.command` registers a command under a group. For example, the ping command can be run using **/ping** but this is not the case for group commands. They are registered with the format of `group_name command_name`. So here, the **command** command of the **uwu** group would be run using **/uwu command**. Note that only group commands can have a single space between them.
+
+---
+
+## An example with a **Group** subclass!
+
+```python
+import discord
+from discord.ext import commands
+from discord import app_commands
+
+class MySlashGroup(app_commands.Group, name="uwu"):
+    def __init__(self, bot: commands.Bot) -> None:
+        self.bot = bot
+        super().__init__()
+
+    @app_commands.command(name="ping", description="...")
+    async def _ping(self, interaction: discord.) -> None:
+        await interaction.response.send_message("pong!")
+    
+    @app_commands.command(name="command", description="...")
+    async def _cmd(self, interaction: discord.Interaction) -> None:
+        await interaction.response.send_message("uwu")
+    
+class MySlashBot(commands.Bot):
+    def __init__(self) -> None:
+        super().__init__(command_prefix="!", intents=discord.Intents.default())
+    
+    async def setup_hook(self) -> None:
+        await self.add_cog(MySlashGroup(self))
+        await self.tree.copy_global_to(discord.Object(id=123456789098765432))
+        await self.tree.sync()
+        
+bot = MySlashBot()
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+- The only difference here too is that the `MySlashGroup` class directly subclasses the **Group** class from discord.app_commands which automatically registers all the methods within the group class to be commands of that specific group. So now, the commands such as `ping` can be run using **/uwu ping** and `command` using **/uwu command**.
+
+
+# Some common methods and features used for slash commands.
+
+--- 
+
+### A common function used for slash commands is the `describe` function. This is used to add descriptions to the arguments of a slash command. The command function can decorated with this function. It goes by the following syntax as shown below.
+
+```python
+from discord.ext import commands
+from discord import app_commands
+import discord
+
+bot = commands.Bot(command_prefix=".", intents=discord.Intents.default())
+#sync the commands
+
+@bot.tree.command(name="echo", description="...")
+@app_commands.describe(text="The text to send!", channel="The channel to send the message in!")
+async def _echo(interaction: discord.Interaction, text: str, channel: discord.TextChannel=None):
+    channel = interaction.channel or channel
+    await channel.send(text)
+```
+
+### Another common issue that most people come across is the time duraction of sending a message with `send_message`. This issue can be tackled by deferring the interaction response using the `defer` method of the `InteractionResponse` class. An example for fixing this issue is shown below.
+
+```python
+import discord
+from discord.ext import commands
+import asyncio
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync the commands
+
+@bot.tree.command(name="time", description="...")
+async def _time(interaction: discord.Interaction, time_to_wait: int):
+    # -------------------------------------------------------------
+    await interaction.response.defer(ephemeral=True, thinking=True)
+    # -------------------------------------------------------------
+    await interaction.edit_original_message(content=f"I will notify you after {time_to_wait} seconds have passed!")
+    await asyncio.sleep(time_to_wait)
+    await interaction.edit_original_message(content=f"{interaction.user.mention}, {time_to_wait} seconds have already passed!")
+```
+
+# Checking for Permissions and Roles!
+
+---
+
+To add a permissions check to a command, the methods are imported through `discord.app_commands.checks`. To check for a member's permissions, the function can be decorated with the [discord.app_commands.checks.has_permissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=has_permissions#discord.app_commands.checks.has_permissions) method. An example to this as follows.
+
+```py
+from discord import app_commands
+from discord.ext import commands
+import discord
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync commands
+
+@bot.tree.command(name="ping")
+@app_commands.checks.has_permissions(manage_messages=True, manage_channels=True) #example permissions
+async def _ping(interaction: discord.Interaction):
+    await interaction.response.send_message("pong!")
+
+```
+
+If the check fails, it will raise a `MissingPermissions` error which can be handled within an app commands error handler! I will discuss about making an error handler later in the gist. All the permissions can be found [here](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discord%20permissions#discord.Permissions).
+
+Other methods that you can decorate the commands with are -
+- `bot_has_permissions` | This checks if the bot has the required permissions for executing the slash command. This raises a [BotMissingPermissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.BotMissingPermissions) exception.
+- `has_role` | This checks if the slash command user has the required role or not. Only **ONE** role name or role ID can be passed to this. If the name is being passed, make sure to have the exact same name as the role name. This raises a [MissingRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingRole) exception.
+- To pass in several role names or role IDs, `has_any_role` can be used to decorate a command. This raises two exceptions -> [MissingAnyRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingAnyRole) and [NoPrivateMessage](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.NoPrivateMessage)
+
+
+# Adding cooldowns to slash commands!
+
+---
+
+Slash Commands within discord.py can be applied cooldowns to in order to prevent spamming of the commands. This can be done through the `discord.app_commands.checks.cooldown` method which can be used to decorate a slash command function and register a cooldown to the function. This raises a [CommandOnCooldown](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=checks%20cooldown#discord.app_commands.CommandOnCooldown) exception if the command is currently on cooldown.
+An example is as follows.
+
+```python
+from discord.ext import commands
+import discord
+
+class Bot(commands.Bot):
+    def __init__(self):
+        super().__init__(command_prefix="uwu", intents=discord.Intents.all())
+        
+    
+    async def setup_hook(self):
+        self.tree.copy_global_to(guild=discord.Object(id=12345678909876543)) 
+        await self.tree.sync()
+        
+    
+bot = Bot()
+
+@bot.tree.command(name="ping")
+# -----------------------------------------
+@discord.app_commands.checks.cooldown(1, 30)
+# -----------------------------------------
+async def ping(interaction: discord.Interaction):
+    await interaction.response.send_message("pong!")
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+- The first argument the `cooldown` method takes is the amount of times the command can be run in a specific period of time.
+- The second argument it takes is the period of time in which the command can be run the specified number of times. 
+- The `CommandOnCooldown` exception can be handled using an error handler. I will discuss about making an error handler for slash commands later in the gist.
+
+
+# Handling errors for slash commands!
+
+---
+
+The Slash Commands exceptions can be handled by overwriting the `on_error` method of the `CommandTree`. The error handler takes two arguments. The first argument is the `Interaction` that took place when the error occurred and the second argument is the error that occurred when the slash commands was invoked. The error is an instance of [discord.app_commands.AppCommandError](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=appcommanderror#discord.app_commands.AppCommandError) which is a subclass of [DiscordException](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discordexception#discord.DiscordException).
+An example to creating an error handler for slash commands is as follows.
+
+```python
+from discord.ext import commands
+from discord import app_commands
+import discord
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync commands
+
+@bot.tree.command(name="ping")
+@app_commands.checks.cooldown(1, 30)
+async def ping(interaction: discord.Interaction):
+    await interaction.response.send_message("pong!")
+
+async def on_tree_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
+    if isinstance(error, app_commands.CommandOnCooldown):
+        return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!")
+
+    elif isinstance(..., ...):
+        ...
+
+    else:
+        raise error
+
+bot.tree.on_error = on_tree_error
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+
+First we create a simple function named as `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here I have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. The `raise error` is just for displayed unhandled errors, i.e. the ones which have not been specificed manually. If this is **removed**, you will not be able to see any exceptions raised due to slash commands and makes debugging the code harder. 
+After creating the error handler function, we set the function as the error handler for the slash commands. Here, `bot.tree.on_error = on_tree_error` overwrites the default `on_error` method of the **CommandTree** class with our custom error handler which has been named as `on_tree_error` here. 
+
+### Creating an error handler for a specific error!
+
+```python
+from discord.ext import commands
+from discord import app_commands
+import discord
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+#sync commands
+
+@bot.tree.command(name="ping")
+app_commands.checks.cooldown(1, 30)
+async def ping(interaction: discord.Interaction):
+    await interaction.response.send_message("pong!")
+
+@ping.error
+async def ping_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
+    if isinstance(error, app_commands.CommandOnCooldown):
+        return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!")
+
+    elif isinstance(error, ...):
+        ...
+
+    else:
+        raise error
+
+bot.run("token")
+```
+
+__**EXPLANATION**__
+
+Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. Please do not call the `error` method.
\ No newline at end of file
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
deleted file mode 100644
index 917c63a1..00000000
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md
+++ /dev/null
@@ -1,459 +0,0 @@
----
-title: Slash Commands with discord.py!
-description: A simple guide to creating slash commands within discord.py!
----
-# DISCORD.PY RESUMPTION CHANGES
-
----
-
-Upon resumption of the most popular discord API wrapper library for python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with addition of features to the library. Some additions to the library include: 
-
-- Buttons support
-- Select Menus support
-- Forms (AKA Modals)
-- Slash commands (AKA Application Commands)
-...and a bunch more handy features!
-
-All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py gist regarding resumption can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6).
-
-# Why this gist?
-
----
-
-This Gist is being created as an update to slash commands (app commands) with explanation and examples. This Gist mainly focuses on **SLASH COMMANDS** for discord.py 2.0 (and above)!
-
-# What Are Slash Commands?
-
----
-
-Slash Commands are the exciting way to build and interact with bots on Discord. With Slash Commands, all you have to do is type / and you're ready to use your favourite bot. You can easily see all the commands a bot has, and validation and error handling help you get the command right the first time.
-
-# Install the latest version for discord.py
-
----
-To use slash commands with discord.py. The latest up-to-date version has to be installed. Make sure that the version is 2.0 or above!
-And make sure to uninstall any third party libraries that support slash commands for discord.py (if any) as a few of them [monkey patch](https://en.wikipedia.org/wiki/Monkey_patch#:~:text=A%20monkey%20patch%20is%20a,running%20instance%20of%20the%20program) discord.py!
-
-The latest and up-to-date usable discord.py version can be installed using `pip install -U git+https://github.com/Rapptz/discord.py`.
-
-If you get an error such as: `'git' is not recognized...`. [Install git](https://git-scm.com/downloads) for your platform. Go through the required steps for installing git and make sure to enable the `add to path` option while in the installation wizard. After installing git you can run `pip install -U git+https://github.com/Rapptz/discord.py` again to install discord.py 2.0.
-**BEFORE MIGRATING TO DISCORD.PY 2.0, PLEASE READ THE CONSEQUENCES OF THE UPDATE [HERE](https://discordpy.readthedocs.io/en/latest/migrating.html)**.
-
-# Basic Structure for Discord.py Slash Commands!
-
----
-
-### Note that Slash Commands in discord.py are also referred to as **Application Commmands** and **App Commands** and every *interaction* is a *webhook*.
-Slash commands in discord.py are held by a container, [CommandTree](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=commandtree#discord.app_commands.CommandTree). A command tree is required to create slash commands in discord.py. This command tree provides a `command` method which decorates an asynchronous function indicating to discord.py that the decorated function is intended to be a slash command. This asynchronous function expects a default argument which acts as the interaction which took place that invoked the slash command. This default argument is an instance of the **Interaction** class from discord.py. Further up, the command logic takes over the behaviour of the slash command.
-
-# Fundamentals for this gist!
-
----
-
-
-The fundamental for this gist will remain to be the `setup_hook`. The `setup_hook` is a special asynchronous method of the Client and Bot class which can be overwritten to perform numerous tasks. This method is safe to use as it is always triggered before any events are dispatched, i.e. this method is triggered before the *IDENTIFY* payload is sent to the discord gateway.
-Note that methods of the Bot class such as `change_presence` will not work in setup_hook as the current application does not have an active connection to the gateway at this point.
-
-__**FOLLOWING IS THE EXAMPLE OF HOW A `SETUP_HOOK` FUNCTION CAN BE DEFINED**__
-
-```python
-import discord
-
-'''This is one way of creating a "setup_hook" method'''
-
-class SlashClient(discord.Client):
-    def __init__(self) -> None:
-        super().__init__(intents=discord.Intents.default())
-
-    async def setup_hook(self) -> None:
-        #perform tasks
-
-'''Another way of creating a "setup_hook" is as follows'''
-
-client = discord.Client(intents=discord.Intents.default())
-async def my_setup_hook() -> None:
-    #perform tasks
-
-client.setup_hook = my_setup_hook
-```
-
-# Basic Slash Command application using discord.py.
-
-#### The `CommandTree` class resides within the `app_commands` of discord.py package.
----
-
-## Slash Command Application with a Client
-
-```python
-import discord
-
-class SlashClient(discord.Client):
-    def __init__(self) -> None:
-        super().__init__(intents=discord.Intents.default())
-        self.tree = discord.app_commands.CommandTree(self)
-
-    async def setup_hook(self) -> None:
-        self.tree.copy_global_to(guild=discord.Object(id=12345678900987654))
-        await self.tree.sync()
-
-client = SlashClient()
-
-@client.tree.command(name="ping", description="...")
-async def _ping(interaction: discord.Interaction) -> None:
-    await interaction.response.send_message("pong")
-
-client.run("token")
-```
-
-
-__**EXPLANATION**__
-
-- `import discord` imports the **discord.py** package.
-- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid.
-- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commands, and binds it to the `discord.Client` subclass instance, so wherever you have access to it, you will also have access to the command tree.
-- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. **Without calling this method, your changes will only be saved locally and will NOT show up on Discord!**
-- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`.
-- Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker.
-- And the classic old `client.run("token")` is used to connect the client to the discord gateway.
-- Note that the `send_message` is a method of the `InteractionResponse` class and `interaction.response` in this case is an instance of the `InteractionResponse` object. The `send_message` method will not function if the response is not sent within 3 seconds of command invocation. I will discuss about how to handle this issue later following the gist.
-
-## Slash Command Application with a Bot
-
-```python
-import discord
-
-class SlashBot(commands.Bot):
-    def __init__(self) -> None:
-        super().__init__(command_prefix=".", intents=discord.Intents.default())
-
-    async def setup_hook(self) -> None:
-        self.tree.copy_global_to(guild=discord.Object(id=12345678900987654))
-        await self.tree.sync()
-
-bot = SlashBot()
-
-@bot.tree.command(name="ping", description="...")
-async def ping(interaction: discord.Interaction) -> None:
-    await interaction.response.send_message("pong")
-
-bot.run("token")
-```
-
-The above example shows a basic slash commands within discord.py using the Bot class.
-
-__**EXPLANATION**__
-
-Most of the explanation is the same as the prior example which featured `SlashClient` which was a subclass of **discord.Client**. Though some minor changes are discussed below.
-
-- The `SlashBot` class now subclasses `discord.ext.commands.Bot` following the passing in of the required arguments to its `__init__` method.
-- `discord.ext.commands.Bot` already consists of an instance of the `CommandTree` class which can be accessed using the `tree` property.
-
-# Slash Commands within a Cog!
-
----
-
-A cog is a collection of commands, listeners, and optional state to help group commands together. More information on them can be found on the [Cogs](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs) page.
-
-## An Example to using cogs with discord.py for slash commands!
-
-```python
-import discord
-from discord.ext import commands
-from discord import app_commands
-
-class MySlashCog(commands.Cog):
-    def __init__(self, bot: commands.Bot) -> None:
-        self.bot = bot
-
-    @app_commands.command(name="ping", description="...")
-    async def ping(self, interaction: discord.Interaction):
-        await interaction.response.send_message("pong!")
-
-class MySlashBot(commands.Bot):
-    def __init__(self) -> None:
-        super().__init__(command_prefix="!", intents=discord.Intents.default())
-
-    async def setup_hook(self) -> None:
-        await self.add_cog(MySlashCog(self))
-        await self.tree.copy_global_to(discord.Object(id=123456789098765432))
-        await self.tree.sync()
-
-bot = MySlashBot()
-
-bot.run("token")
-```
-
-__**EXPLANATION**__
-
-- Firstly, `import discord` imports the **discord.py** package. `from discord import app_commands` imports the `app_commands` directory from the **discord.py** root directory. `from discord.ext import commands` imports the commands extension.
-- Further up, `class MySlashCog(commands.Cog)` is a class subclassing the `Cog` class. You can read more about this [here](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs).
-- `def __init__(self, bot: commands.Bot): self.bot = bot` is the constructor method of the class that is always run when the class is instantiated and that is why we pass in a **Bot** object whenever we create an instance of the cog class.
-- Following up is the `@app_commands.command(name="ping", description="...")` decorator. This decorator basically functions the same as a `bot.tree.command` but since the cog currently does not have a **bot**, the `app_commands.command` decorator is used instead. The next two lines follow the same structure for slash commands with **self** added as the first parameter to the function as it is a method of a class.
-- The next up lines are mostly the same.
-- Talking about the first line inside the `setup_hook` is the `add_cog` method of the **Bot** class. And since **self** acts as the **instance** of the current class, we use **self** to use the `add_cog` method of the **Bot** class as we are inside a subclassed class of the **Bot** class. Then we pass in **self** to the `add_cog` method as the `__init__` function of the **MySlashCog** cog accepts a `Bot` object.
-- After that we instantiate the `MySlashBot` class and run the bot using the **run** method which executes our setup_hook function and our commands get loaded and synced. The bot is now ready to use!
-
-# An Example to using groups with discord.py for slash commands!
-
----
-
-## An example with optional group!
-
-```python
-import discord
-from discord.ext import commands
-from discord import app_commands
-
-class MySlashGroupCog(commands.Cog):
-    def __init__(self, bot: commands.Bot) -> None:
-        self.bot = bot
-
-    #--------------------------------------------------------
-    group = app_commands.Group(name="uwu", description="...")
-    #--------------------------------------------------------
-
-    @app_commands.command(name="ping", description="...")
-    async def _ping(self, interaction: discord.) -> None:
-        await interaction.response.send_message("pong!")
-
-    @group.command(name="command", description="...")
-    async def _cmd(self, interaction: discord.Interaction) -> None:
-        await interaction.response.send_message("uwu")
-
-class MySlashBot(commands.Bot):
-    def __init__(self) -> None:
-        super().__init__(command_prefix="!", intents=discord.Intents.default())
-
-    async def setup_hook(self) -> None:
-        await self.add_cog(MySlashGroupCog(self))
-        await self.tree.copy_global_to(discord.Object(id=123456789098765432))
-        await self.tree.sync()
-
-bot = MySlashBot()
-
-bot.run("token")
-```
-
-__**EXPLANATION**__
-- The only difference used here is `group = app_commands.Group(name="uwu", description="...")` and `group.command`. `app_commands.Group` is used to initiate a group while `group.command` registers a command under a group. For example, the ping command can be run using **/ping** but this is not the case for group commands. They are registered with the format of `group_name command_name`. So here, the **command** command of the **uwu** group would be run using **/uwu command**. Note that only group commands can have a single space between them.
-
----
-
-## An example with a **Group** subclass!
-
-```python
-import discord
-from discord.ext import commands
-from discord import app_commands
-
-class MySlashGroup(commands.GroupCog, name="uwu"):
-    def __init__(self, bot: commands.Bot) -> None:
-        self.bot = bot
-        super().__init__()
-
-    @app_commands.command(name="ping", description="...")
-    async def _ping(self, interaction: discord.) -> None:
-        await interaction.response.send_message("pong!")
-
-    @app_commands.command(name="command", description="...")
-    async def _cmd(self, interaction: discord.Interaction) -> None:
-        await interaction.response.send_message("uwu")
-
-class MySlashBot(commands.Bot):
-    def __init__(self) -> None:
-        super().__init__(command_prefix="!", intents=discord.Intents.default())
-
-    async def setup_hook(self) -> None:
-        await self.add_cog(MySlashGroup(self))
-        await self.tree.copy_global_to(discord.Object(id=123456789098765432))
-        await self.tree.sync()
-
-bot = MySlashBot()
-
-bot.run("token")
-```
-
-__**EXPLANATION**__
-- The only difference here too is that the `MySlashGroup` class directly subclasses the **GroupCog** class from discord.ext.commands which automatically registers all the methods within the group class to be commands of that specific group. So now, the commands such as `ping` can be run using **/uwu ping** and `command` using **/uwu command**.
-
-
-# Some common methods and features used for slash commands.
-
----
-
-### A common function used for slash commands is the `describe` function. This is used to add descriptions to the arguments of a slash command. The command function can decorated with this function. It goes by the following syntax as shown below.
-
-```python
-from discord.ext import commands
-from discord import app_commands
-import discord
-
-bot = commands.Bot(command_prefix=".", intents=discord.Intents.default())
-#sync the commands
-
-@bot.tree.command(name="echo", description="...")
-@app_commands.describe(text="The text to send!", channel="The channel to send the message in!")
-async def _echo(interaction: discord.Interaction, text: str, channel: discord.TextChannel=None):
-    channel = interaction.channel or channel
-    await channel.send(text)
-```
-
-### Another common issue that most people come across is the time duraction of sending a message with `send_message`. This issue can be tackled by deferring the interaction response using the `defer` method of the `InteractionResponse` class. An example for fixing this issue is shown below.
-
-```python
-import discord
-from discord.ext import commands
-import asyncio
-
-bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
-#sync the commands
-
-@bot.tree.command(name="time", description="...")
-async def _time(interaction: discord.Interaction, time_to_wait: int):
-    # -------------------------------------------------------------
-    await interaction.response.defer(ephemeral=True, thinking=True)
-    # -------------------------------------------------------------
-    await interaction.edit_original_message(content=f"I will notify you after {time_to_wait} seconds have passed!")
-    await asyncio.sleep(time_to_wait)
-    await interaction.edit_original_message(content=f"{interaction.user.mention}, {time_to_wait} seconds have already passed!")
-```
-
-# Checking for Permissions and Roles!
-
----
-
-To add a permissions check to a command, the methods are imported through `discord.app_commands.checks`. To check for a member's permissions, the function can be decorated with the [discord.app_commands.checks.has_permissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=has_permissions#discord.app_commands.checks.has_permissions) method. An example to this as follows.
-
-```py
-from discord import app_commands
-from discord.ext import commands
-import discord
-
-bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
-#sync commands
-
-@bot.tree.command(name="ping")
-@app_commands.checks.has_permissions(manage_messages=True, manage_channels=True) #example permissions
-async def _ping(interaction: discord.Interaction):
-    await interaction.response.send_message("pong!")
-
-```
-
-If the check fails, it will raise a `MissingPermissions` error which can be handled within an app commands error handler! I will discuss about making an error handler later in the gist. All the permissions can be found [here](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discord%20permissions#discord.Permissions).
-
-Other methods that you can decorate the commands with are -
-- `bot_has_permissions` | This checks if the bot has the required permissions for executing the slash command. This raises a [BotMissingPermissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.BotMissingPermissions) exception.
-- `has_role` | This checks if the slash command user has the required role or not. Only **ONE** role name or role ID can be passed to this. If the name is being passed, make sure to have the exact same name as the role name. This raises a [MissingRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingRole) exception.
-- To pass in several role names or role IDs, `has_any_role` can be used to decorate a command. This raises two exceptions -> [MissingAnyRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingAnyRole) and [NoPrivateMessage](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.NoPrivateMessage)
-
-
-# Adding cooldowns to slash commands!
-
----
-
-Slash Commands within discord.py can be applied cooldowns to in order to prevent spamming of the commands. This can be done through the `discord.app_commands.checks.cooldown` method which can be used to decorate a slash command function and register a cooldown to the function. This raises a [CommandOnCooldown](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=checks%20cooldown#discord.app_commands.CommandOnCooldown) exception if the command is currently on cooldown.
-An example is as follows.
-
-```python
-from discord.ext import commands
-import discord
-
-class Bot(commands.Bot):
-    def __init__(self):
-        super().__init__(command_prefix="uwu", intents=discord.Intents.all())
-
-
-    async def setup_hook(self):
-        self.tree.copy_global_to(guild=discord.Object(id=12345678909876543))
-        await self.tree.sync()
-
-
-bot = Bot()
-
-@bot.tree.command(name="ping")
-# -----------------------------------------
-@discord.app_commands.checks.cooldown(1, 30)
-# -----------------------------------------
-async def ping(interaction: discord.Interaction):
-    await interaction.response.send_message("pong!")
-
-bot.run("token")
-```
-
-__**EXPLANATION**__
-- The first argument the `cooldown` method takes is the amount of times the command can be run in a specific period of time.
-- The second argument it takes is the period of time in which the command can be run the specified number of times.
-- The `CommandOnCooldown` exception can be handled using an error handler. I will discuss about making an error handler for slash commands later in the gist.
-
-
-# Handling errors for slash commands!
-
----
-
-The Slash Commands exceptions can be handled by overwriting the `on_error` method of the `CommandTree`. The error handler takes two arguments. The first argument is the `Interaction` that took place when the error occurred and the second argument is the error that occurred when the slash commands was invoked. The error is an instance of [discord.app_commands.AppCommandError](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=appcommanderror#discord.app_commands.AppCommandError) which is a subclass of [DiscordException](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discordexception#discord.DiscordException).
-An example to creating an error handler for slash commands is as follows.
-
-```python
-from discord.ext import commands
-from discord import app_commands
-import discord
-
-bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
-#sync commands
-
-@bot.tree.command(name="ping")
-app_commands.checks.cooldown(1, 30)
-async def ping(interaction: discord.Interaction):
-    await interaction.response.send_message("pong!")
-
-async def on_tree_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
-    if isinstance(error, app_commands.CommandOnCooldown):
-        return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!")
-
-    elif isinstance(error, ...):
-        ...
-
-    else:
-        raise error
-
-bot.tree.on_error = on_tree_error
-
-bot.run("token")
-```
-
-__**EXPLANATION**__
-
-First we create a simple function named as `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here I have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. After creating the error handler function, we set the function as the error handler for the slash commands. Here, `bot.tree.on_error = on_tree_error` overwrites the default `on_error` method of the **CommandTree** class with our custom error handler which has been named as `on_tree_error` here.
-
-### Creating an error handler for a specific error!
-
-```python
-from discord.ext import commands
-from discord import app_commands
-import discord
-
-bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
-#sync commands
-
-@bot.tree.command(name="ping")
-@app_commands.checks.cooldown(1, 30)
-async def ping(interaction: discord.Interaction):
-    await interaction.response.send_message("pong!")
-
-@ping.error
-async def ping_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
-    if isinstance(error, app_commands.CommandOnCooldown):
-        return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!")
-
-    elif isinstance(error, ...):
-        ...
-
-    else:
-        raise error
-
-bot.run("token")
-```
-
-__**EXPLANATION**__
-
-Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. Please do not call the `error` method.
-- 
cgit v1.2.3
From 589ffad6935a155df4751401f884c523c777b6c6 Mon Sep 17 00:00:00 2001
From: Blue-Puddle 
Date: Fri, 1 Jul 2022 16:20:33 +0100
Subject: fix app_commands.md
---
 pydis_site/apps/content/resources/guides/python-guides/app_commands.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/app_commands.md b/pydis_site/apps/content/resources/guides/python-guides/app_commands.md
index d97b849a..821ac577 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/app_commands.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/app_commands.md
@@ -411,7 +411,7 @@ bot.run("token")
 
 __**EXPLANATION**__
 
-First we create a simple function named as `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here I have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. The `raise error` is just for displayed unhandled errors, i.e. the ones which have not been specificed manually. If this is **removed**, you will not be able to see any exceptions raised due to slash commands and makes debugging the code harder. 
+First we create a simple function named as `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here I have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. The `raise error` is just for displaying unhandled errors, i.e. the ones which have not been handled manually. If this is **removed**, you will not be able to see any exceptions raised by slash commands and makes debugging the code harder.
 After creating the error handler function, we set the function as the error handler for the slash commands. Here, `bot.tree.on_error = on_tree_error` overwrites the default `on_error` method of the **CommandTree** class with our custom error handler which has been named as `on_tree_error` here. 
 
 ### Creating an error handler for a specific error!
-- 
cgit v1.2.3
From 79413d48fbf5542dd5aec9991239daccec07ef8f Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Fri, 1 Jul 2022 20:46:46 +0200
Subject: Move ASGI card item to bulma notification at the top
---
 .../events/pages/code-jams/9/frameworks.html       | 22 ++++++++++------------
 1 file changed, 10 insertions(+), 12 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 91007197..6ea2acfc 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -17,6 +17,16 @@
         If there is a library not listed below that you think should be here, you're welcome to discuss it with the Events Team over at the server.
     
 
+    
+        
Most of the below frameworks implement what is called the ASGI Specification.
+            This specification documents how the frameworks should interact with ASGI servers and call the frameworks.
+            You are also allowed to work with the ASGI specification directly without a framework, if your team so chooses to.
+            Refer to the specification online.
+        
+    
 
+
+    
+
     
         
 
-    
-        
-            
-                
ASGI
-                
ASGI is the specification your favourite frameworks are built on. This is not a framework in of itself, but listed here for completeness.
-            
-        
-        
-    
-
     
         
             
-- 
cgit v1.2.3
From 0e235abbcf121d9221a2a16a0cfd582331a63f57 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Fri, 1 Jul 2022 23:07:35 +0200
Subject: Remove extra wording in ASGI explanation
Co-authored-by: Kieran Siek 
---
 pydis_site/templates/events/pages/code-jams/9/frameworks.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 6ea2acfc..cc171d54 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -19,7 +19,7 @@
 
     
         Most of the below frameworks implement what is called the ASGI Specification.
-            This specification documents how the frameworks should interact with ASGI servers and call the frameworks.
+            This specification documents how the frameworks should interact with ASGI servers.
             You are also allowed to work with the ASGI specification directly without a framework, if your team so chooses to.
             Refer to the specification online.
         
-- 
cgit v1.2.3
From 9714f81fed18b3b9494b0dee08a859ed6bdbe756 Mon Sep 17 00:00:00 2001
From: mbaruh 
Date: Sun, 10 Jul 2022 17:12:31 +0300
Subject: Add starlite and sanic to cj9 frameworks
---
 .../events/pages/code-jams/9/frameworks.html       | 28 ++++++++++++++++++++++
 1 file changed, 28 insertions(+)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index cc171d54..355bf9c3 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -110,6 +110,34 @@
          
     
 
+    
+        
+            
+                
Starlite
+                
Starlite is a light and flexible ASGI API framework, using Starlette and Pydantic as foundations.
+                
+            
+        
+        
+    
+
+    
+        
+            
+                
Sanic
+                
Sanic is an ASGI compliant web framework designed for speed and simplicity.
+                
+            
+        
+        
+    
+
 
 {% endblock %}
 
-- 
cgit v1.2.3
From 1832a86afb5e9a13806bd759d22eb8a8eb8c7467 Mon Sep 17 00:00:00 2001
From: mina <75038675+minalike@users.noreply.github.com>
Date: Sun, 10 Jul 2022 19:15:25 -0400
Subject: Fix linting
---
 pydis_site/apps/resources/resources/the_algorithms_github.yaml | 1 -
 1 file changed, 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/resources/resources/the_algorithms_github.yaml b/pydis_site/apps/resources/resources/the_algorithms_github.yaml
index 10052fd3..30a0a5da 100644
--- a/pydis_site/apps/resources/resources/the_algorithms_github.yaml
+++ b/pydis_site/apps/resources/resources/the_algorithms_github.yaml
@@ -15,4 +15,3 @@ tags:
     - intermediate
   type:
     - tutorial
-
-- 
cgit v1.2.3
From 586e7e9de0f483dc4adb650fb1411a5b731d023b Mon Sep 17 00:00:00 2001
From: mina <75038675+minalike@users.noreply.github.com>
Date: Sun, 10 Jul 2022 19:30:25 -0400
Subject: Use font awesome lock icon that is available under free license
---
 pydis_site/apps/resources/templatetags/get_category_icon.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/resources/templatetags/get_category_icon.py b/pydis_site/apps/resources/templatetags/get_category_icon.py
index 97b5e0a7..30bc4eaa 100644
--- a/pydis_site/apps/resources/templatetags/get_category_icon.py
+++ b/pydis_site/apps/resources/templatetags/get_category_icon.py
@@ -21,7 +21,7 @@ _ICONS = {
     "Paid": "fa-dollar-sign",
     "Podcast": "fa-microphone-alt",
     "Project Ideas": "fa-lightbulb-o",
-    "Security": "lock-alt",
+    "Security": "fa-solid fa-lock",
     "Software Design": "fa-paint-brush",
     "Subscription": "fa-credit-card",
     "Testing": "fa-vial",
-- 
cgit v1.2.3
From c24ccccde65cd8d5601ade63e47f2167ba64b5ee Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Sat, 9 Jul 2022 22:27:11 +0400
Subject: Switch Out requests For httpx
The requests library has been replaced by httpx. It's a drop-in
replacement, but provides a better interface for certain things,
such as client sessions, and sync/async support.
Signed-off-by: Hassan Abouelela 
---
 poetry.lock                                        | 120 +++++++++++++++++++--
 .../apps/home/tests/test_repodata_helpers.py       |  12 +--
 pydis_site/apps/home/views/home.py                 |   6 +-
 pyproject.toml                                     |   2 +-
 4 files changed, 120 insertions(+), 20 deletions(-)
(limited to 'pydis_site')
diff --git a/poetry.lock b/poetry.lock
index 3b26c275..fcdc9d84 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,3 +1,20 @@
+[[package]]
+name = "anyio"
+version = "3.6.1"
+description = "High level compatibility layer for multiple asynchronous event loop implementations"
+category = "main"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+idna = ">=2.8"
+sniffio = ">=1.1"
+
+[package.extras]
+doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
+test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
+trio = ["trio (>=0.16)"]
+
 [[package]]
 name = "asgiref"
 version = "3.5.0"
@@ -373,6 +390,52 @@ gevent = ["gevent (>=0.13)"]
 setproctitle = ["setproctitle"]
 tornado = ["tornado (>=0.2)"]
 
+[[package]]
+name = "h11"
+version = "0.12.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "httpcore"
+version = "0.15.0"
+description = "A minimal low-level HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+anyio = ">=3.0.0,<4.0.0"
+certifi = "*"
+h11 = ">=0.11,<0.13"
+sniffio = ">=1.0.0,<2.0.0"
+
+[package.extras]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
+[[package]]
+name = "httpx"
+version = "0.23.0"
+description = "The next generation HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+certifi = "*"
+httpcore = ">=0.15.0,<0.16.0"
+rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotlicffi", "brotli"]
+cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10,<13)", "pygments (>=2.0.0,<3.0.0)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
 [[package]]
 name = "identify"
 version = "2.4.6"
@@ -617,21 +680,35 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
 
 [[package]]
 name = "requests"
-version = "2.27.1"
+version = "2.28.1"
 description = "Python HTTP for Humans."
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+python-versions = ">=3.7, <4"
 
 [package.dependencies]
 certifi = ">=2017.4.17"
-charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
-idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
+charset-normalizer = ">=2,<3"
+idna = ">=2.5,<4"
 urllib3 = ">=1.21.1,<1.27"
 
 [package.extras]
-socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
-use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "rfc3986"
+version = "1.5.0"
+description = "Validating URI References per RFC 3986"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
+
+[package.extras]
+idna2008 = ["idna"]
 
 [[package]]
 name = "sentry-sdk"
@@ -677,6 +754,14 @@ category = "dev"
 optional = false
 python-versions = ">=3.6"
 
+[[package]]
+name = "sniffio"
+version = "1.2.0"
+description = "Sniff out which async library your code is running under"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
 [[package]]
 name = "snowballstemmer"
 version = "2.2.0"
@@ -782,9 +867,13 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-
 [metadata]
 lock-version = "1.1"
 python-versions = "3.9.*"
-content-hash = "fc9b20c33c65a289122d710844285ac20d7598e65c7f8237f8903509f5b2dea4"
+content-hash = "c94949e29f868689d9c99379dbf4f9479e2ddfe5e6d49e15b57d016210a50379"
 
 [metadata.files]
+anyio = [
+    {file = "anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"},
+    {file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"},
+]
 asgiref = [
     {file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"},
     {file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"},
@@ -959,6 +1048,12 @@ gunicorn = [
     {file = "gunicorn-20.0.4-py2.py3-none-any.whl", hash = "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"},
     {file = "gunicorn-20.0.4.tar.gz", hash = "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626"},
 ]
+h11 = [
+    {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
+    {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
+]
+httpcore = []
+httpx = []
 identify = [
     {file = "identify-2.4.6-py2.py3-none-any.whl", hash = "sha256:cf06b1639e0dca0c184b1504d8b73448c99a68e004a80524c7923b95f7b6837c"},
     {file = "identify-2.4.6.tar.gz", hash = "sha256:233679e3f61a02015d4293dbccf16aa0e4996f868bd114688b8c124f18826706"},
@@ -1149,9 +1244,10 @@ pyyaml = [
     {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
     {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
 ]
-requests = [
-    {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
-    {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
+requests = []
+rfc3986 = [
+    {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
+    {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
 ]
 sentry-sdk = [
     {file = "sentry-sdk-0.20.3.tar.gz", hash = "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237"},
@@ -1165,6 +1261,10 @@ smmap = [
     {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"},
     {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"},
 ]
+sniffio = [
+    {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
+    {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
+]
 snowballstemmer = [
     {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
     {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
diff --git a/pydis_site/apps/home/tests/test_repodata_helpers.py b/pydis_site/apps/home/tests/test_repodata_helpers.py
index d43bd28e..4007eded 100644
--- a/pydis_site/apps/home/tests/test_repodata_helpers.py
+++ b/pydis_site/apps/home/tests/test_repodata_helpers.py
@@ -36,7 +36,7 @@ class TestRepositoryMetadataHelpers(TestCase):
         """Executed before each test method."""
         self.home_view = HomeView()
 
-    @mock.patch('requests.get', side_effect=mocked_requests_get)
+    @mock.patch('httpx.get', side_effect=mocked_requests_get)
     def test_returns_metadata(self, _: mock.MagicMock):
         """Test if the _get_repo_data helper actually returns what it should."""
         metadata = self.home_view._get_repo_data()
@@ -59,7 +59,7 @@ class TestRepositoryMetadataHelpers(TestCase):
         self.assertIsInstance(metadata[0], RepositoryMetadata)
         self.assertIsInstance(str(metadata[0]), str)
 
-    @mock.patch('requests.get', side_effect=mocked_requests_get)
+    @mock.patch('httpx.get', side_effect=mocked_requests_get)
     def test_refresh_stale_metadata(self, _: mock.MagicMock):
         """Test if the _get_repo_data helper will refresh when the data is stale."""
         repo_data = RepositoryMetadata(
@@ -75,7 +75,7 @@ class TestRepositoryMetadataHelpers(TestCase):
 
         self.assertIsInstance(metadata[0], RepositoryMetadata)
 
-    @mock.patch('requests.get', side_effect=mocked_requests_get)
+    @mock.patch('httpx.get', side_effect=mocked_requests_get)
     def test_returns_api_data(self, _: mock.MagicMock):
         """Tests if the _get_api_data helper returns what it should."""
         api_data = self.home_view._get_api_data()
@@ -86,7 +86,7 @@ class TestRepositoryMetadataHelpers(TestCase):
         self.assertIn(repo, api_data.keys())
         self.assertIn("stargazers_count", api_data[repo])
 
-    @mock.patch('requests.get', side_effect=mocked_requests_get)
+    @mock.patch('httpx.get', side_effect=mocked_requests_get)
     def test_mocked_requests_get(self, mock_get: mock.MagicMock):
         """Tests if our mocked_requests_get is returning what it should."""
         success_data = mock_get(HomeView.github_api)
@@ -98,7 +98,7 @@ class TestRepositoryMetadataHelpers(TestCase):
         self.assertIsNotNone(success_data.json_data)
         self.assertIsNone(fail_data.json_data)
 
-    @mock.patch('requests.get')
+    @mock.patch('httpx.get')
     def test_falls_back_to_database_on_error(self, mock_get: mock.MagicMock):
         """Tests that fallback to the database is performed when we get garbage back."""
         repo_data = RepositoryMetadata(
@@ -117,7 +117,7 @@ class TestRepositoryMetadataHelpers(TestCase):
         [item] = metadata
         self.assertEqual(item, repo_data)
 
-    @mock.patch('requests.get')
+    @mock.patch('httpx.get')
     def test_falls_back_to_database_on_error_without_entries(self, mock_get: mock.MagicMock):
         """Tests that fallback to the database is performed when we get garbage back."""
         mock_get.return_value.json.return_value = ['garbage']
diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py
index 69e706c5..9bb1f8fd 100644
--- a/pydis_site/apps/home/views/home.py
+++ b/pydis_site/apps/home/views/home.py
@@ -1,7 +1,7 @@
 import logging
 from typing import Dict, List
 
-import requests
+import httpx
 from django.core.handlers.wsgi import WSGIRequest
 from django.http import HttpResponse
 from django.shortcuts import render
@@ -56,12 +56,12 @@ class HomeView(View):
         repo_dict = {}
         try:
             # Fetch the data from the GitHub API
-            api_data: List[dict] = requests.get(
+            api_data: List[dict] = httpx.get(
                 self.github_api,
                 headers=self.headers,
                 timeout=settings.TIMEOUT_PERIOD
             ).json()
-        except requests.exceptions.Timeout:
+        except httpx.TimeoutException:
             log.error("Request to fetch GitHub repository metadata for timed out!")
             return repo_dict
 
diff --git a/pyproject.toml b/pyproject.toml
index b350836e..01e7eda2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,7 +14,7 @@ djangorestframework = "~=3.12.0"
 psycopg2-binary = "~=2.8.0"
 django-simple-bulma = "~=2.4"
 whitenoise = "~=5.0"
-requests = "~=2.21"
+httpx = "~=0.23.0"
 pyyaml = "~=5.1"
 gunicorn = "~=20.0.4"
 sentry-sdk = "~=0.19"
-- 
cgit v1.2.3
From 7b40cd8143fea0beb195c6940bf2356970fc6958 Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Mon, 11 Jul 2022 04:27:16 +0400
Subject: Drop Migration Tests
The migration test suite was not really used, and it doesn't entirely
make sense to test a constant unchanging process either. Its behavior
is also very coupled with django's internals, and locks us into the
current version and setup.
Signed-off-by: Hassan Abouelela 
---
 pydis_site/apps/api/tests/migrations/__init__.py   |   1 -
 pydis_site/apps/api/tests/migrations/base.py       | 102 -----
 .../migrations/test_active_infraction_migration.py | 496 ---------------------
 pydis_site/apps/api/tests/migrations/test_base.py  | 135 ------
 4 files changed, 734 deletions(-)
 delete mode 100644 pydis_site/apps/api/tests/migrations/__init__.py
 delete mode 100644 pydis_site/apps/api/tests/migrations/base.py
 delete mode 100644 pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py
 delete mode 100644 pydis_site/apps/api/tests/migrations/test_base.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/tests/migrations/__init__.py b/pydis_site/apps/api/tests/migrations/__init__.py
deleted file mode 100644
index 38e42ffc..00000000
--- a/pydis_site/apps/api/tests/migrations/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""This submodule contains tests for functions used in data migrations."""
diff --git a/pydis_site/apps/api/tests/migrations/base.py b/pydis_site/apps/api/tests/migrations/base.py
deleted file mode 100644
index 0c0a5bd0..00000000
--- a/pydis_site/apps/api/tests/migrations/base.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""Includes utilities for testing migrations."""
-from django.db import connection
-from django.db.migrations.executor import MigrationExecutor
-from django.test import TestCase
-
-
-class MigrationsTestCase(TestCase):
-    """
-    A `TestCase` subclass to test migration files.
-
-    To be able to properly test a migration, we will need to inject data into the test database
-    before the migrations we want to test are applied, but after the older migrations have been
-    applied. This makes sure that we are testing "as if" we were actually applying this migration
-    to a database in the state it was in before introducing the new migration.
-
-    To set up a MigrationsTestCase, create a subclass of this class and set the following
-    class-level attributes:
-
-    - app: The name of the app that contains the migrations (e.g., `'api'`)
-    - migration_prior: The name* of the last migration file before the migrations you want to test
-    - migration_target: The name* of the last migration file we want to test
-
-    *) Specify the file names without a path or the `.py` file extension.
-
-    Additionally, overwrite the `setUpMigrationData` in the subclass to inject data into the
-    database before the migrations we want to test are applied. Please read the docstring of the
-    method for more information. An optional hook, `setUpPostMigrationData` is also provided.
-    """
-
-    # These class-level attributes should be set in classes that inherit from this base class.
-    app = None
-    migration_prior = None
-    migration_target = None
-
-    @classmethod
-    def setUpTestData(cls):
-        """
-        Injects data into the test database prior to the migration we're trying to test.
-
-        This class methods reverts the test database back to the state of the last migration file
-        prior to the migrations we want to test. It will then allow the user to inject data into the
-        test database by calling the `setUpMigrationData` hook. After the data has been injected, it
-        will apply the migrations we want to test and call the `setUpPostMigrationData` hook. The
-        user can now test if the migration correctly migrated the injected test data.
-        """
-        if not cls.app:
-            raise ValueError("The `app` attribute was not set.")
-
-        if not cls.migration_prior or not cls.migration_target:
-            raise ValueError("Both ` migration_prior` and `migration_target` need to be set.")
-
-        cls.migrate_from = [(cls.app, cls.migration_prior)]
-        cls.migrate_to = [(cls.app, cls.migration_target)]
-
-        # Reverse to database state prior to the migrations we want to test
-        executor = MigrationExecutor(connection)
-        executor.migrate(cls.migrate_from)
-
-        # Call the data injection hook with the current state of the project
-        old_apps = executor.loader.project_state(cls.migrate_from).apps
-        cls.setUpMigrationData(old_apps)
-
-        # Run the migrations we want to test
-        executor = MigrationExecutor(connection)
-        executor.loader.build_graph()
-        executor.migrate(cls.migrate_to)
-
-        # Save the project state so we're able to work with the correct model states
-        cls.apps = executor.loader.project_state(cls.migrate_to).apps
-
-        # Call `setUpPostMigrationData` to potentially set up post migration data used in testing
-        cls.setUpPostMigrationData(cls.apps)
-
-    @classmethod
-    def setUpMigrationData(cls, apps):
-        """
-        Override this method to inject data into the test database before the migration is applied.
-
-        This method will be called after setting up the database according to the migrations that
-        come before the migration(s) we are trying to test, but before the to-be-tested migration(s)
-        are applied. This allows us to simulate a database state just prior to the migrations we are
-        trying to test.
-
-        To make sure we're creating objects according to the state the models were in at this point
-        in the migration history, use `apps.get_model(app_name: str, model_name: str)` to get the
-        appropriate model, e.g.:
-
-        >>> Infraction = apps.get_model('api', 'Infraction')
-        """
-        pass
-
-    @classmethod
-    def setUpPostMigrationData(cls, apps):
-        """
-        Set up additional test data after the target migration has been applied.
-
-        Use `apps.get_model(app_name: str, model_name: str)` to get the correct instances of the
-        model classes:
-
-        >>> Infraction = apps.get_model('api', 'Infraction')
-        """
-        pass
diff --git a/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py b/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py
deleted file mode 100644
index 8dc29b34..00000000
--- a/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py
+++ /dev/null
@@ -1,496 +0,0 @@
-"""Tests for the data migration in `filename`."""
-import logging
-from collections import ChainMap, namedtuple
-from datetime import timedelta
-from itertools import count
-from typing import Dict, Iterable, Type, Union
-
-from django.db.models import Q
-from django.forms.models import model_to_dict
-from django.utils import timezone
-
-from pydis_site.apps.api.models import Infraction, User
-from .base import MigrationsTestCase
-
-log = logging.getLogger(__name__)
-log.setLevel(logging.DEBUG)
-
-
-InfractionHistory = namedtuple('InfractionHistory', ("user_id", "infraction_history"))
-
-
-class InfractionFactory:
-    """Factory that creates infractions for a User instance."""
-
-    infraction_id = count(1)
-    user_id = count(1)
-    default_values = {
-        'active': True,
-        'expires_at': None,
-        'hidden': False,
-    }
-
-    @classmethod
-    def create(
-        cls,
-        actor: User,
-        infractions: Iterable[Dict[str, Union[str, int, bool]]],
-        infraction_model: Type[Infraction] = Infraction,
-        user_model: Type[User] = User,
-    ) -> InfractionHistory:
-        """
-        Creates `infractions` for the `user` with the given `actor`.
-
-        The `infractions` dictionary can contain the following fields:
-         - `type` (required)
-         - `active` (default: True)
-         - `expires_at` (default: None; i.e, permanent)
-         - `hidden` (default: False).
-
-        The parameters `infraction_model` and `user_model` can be used to pass in an instance of
-        both model classes from a different migration/project state.
-        """
-        user_id = next(cls.user_id)
-        user = user_model.objects.create(
-            id=user_id,
-            name=f"Infracted user {user_id}",
-            discriminator=user_id,
-            avatar_hash=None,
-        )
-        infraction_history = []
-
-        for infraction in infractions:
-            infraction = dict(infraction)
-            infraction["id"] = next(cls.infraction_id)
-            infraction = ChainMap(infraction, cls.default_values)
-            new_infraction = infraction_model.objects.create(
-                user=user,
-                actor=actor,
-                type=infraction["type"],
-                reason=f"`{infraction['type']}` infraction (ID: {infraction['id']} of {user}",
-                active=infraction['active'],
-                hidden=infraction['hidden'],
-                expires_at=infraction['expires_at'],
-            )
-            infraction_history.append(new_infraction)
-
-        return InfractionHistory(user_id=user_id, infraction_history=infraction_history)
-
-
-class InfractionFactoryTests(MigrationsTestCase):
-    """Tests for the InfractionFactory."""
-
-    app = "api"
-    migration_prior = "0046_reminder_jump_url"
-    migration_target = "0046_reminder_jump_url"
-
-    @classmethod
-    def setUpPostMigrationData(cls, apps):
-        """Create a default actor for all infractions."""
-        cls.infraction_model = apps.get_model('api', 'Infraction')
-        cls.user_model = apps.get_model('api', 'User')
-
-        cls.actor = cls.user_model.objects.create(
-            id=9999,
-            name="Unknown Moderator",
-            discriminator=1040,
-            avatar_hash=None,
-        )
-
-    def test_infraction_factory_total_count(self):
-        """Does the test database hold as many infractions as we tried to create?"""
-        InfractionFactory.create(
-            actor=self.actor,
-            infractions=(
-                {'type': 'kick', 'active': False, 'hidden': False},
-                {'type': 'ban', 'active': True, 'hidden': False},
-                {'type': 'note', 'active': False, 'hidden': True},
-            ),
-            infraction_model=self.infraction_model,
-            user_model=self.user_model,
-        )
-        database_count = Infraction.objects.all().count()
-        self.assertEqual(3, database_count)
-
-    def test_infraction_factory_multiple_users(self):
-        """Does the test database hold as many infractions as we tried to create?"""
-        for _user in range(5):
-            InfractionFactory.create(
-                actor=self.actor,
-                infractions=(
-                    {'type': 'kick', 'active': False, 'hidden': True},
-                    {'type': 'ban', 'active': True, 'hidden': False},
-                ),
-                infraction_model=self.infraction_model,
-                user_model=self.user_model,
-            )
-
-        # Check if infractions and users are recorded properly in the database
-        database_count = Infraction.objects.all().count()
-        self.assertEqual(database_count, 10)
-
-        user_count = User.objects.all().count()
-        self.assertEqual(user_count, 5 + 1)
-
-    def test_infraction_factory_sets_correct_fields(self):
-        """Does the InfractionFactory set the correct attributes?"""
-        infractions = (
-            {
-                'type': 'note',
-                'active': False,
-                'hidden': True,
-                'expires_at': timezone.now()
-            },
-            {'type': 'warning', 'active': False, 'hidden': False, 'expires_at': None},
-            {'type': 'watch', 'active': False, 'hidden': True, 'expires_at': None},
-            {'type': 'mute', 'active': True, 'hidden': False, 'expires_at': None},
-            {'type': 'kick', 'active': True, 'hidden': True, 'expires_at': None},
-            {'type': 'ban', 'active': True, 'hidden': False, 'expires_at': None},
-            {
-                'type': 'superstar',
-                'active': True,
-                'hidden': True,
-                'expires_at': timezone.now()
-            },
-        )
-
-        InfractionFactory.create(
-            actor=self.actor,
-            infractions=infractions,
-            infraction_model=self.infraction_model,
-            user_model=self.user_model,
-        )
-
-        for infraction in infractions:
-            with self.subTest(**infraction):
-                self.assertTrue(Infraction.objects.filter(**infraction).exists())
-
-
-class ActiveInfractionMigrationTests(MigrationsTestCase):
-    """
-    Tests the active infraction data migration.
-
-    The active infraction data migration should do the following things:
-
-    1.  migrates all active notes, warnings, and kicks to an inactive status;
-    2.  migrates all users with multiple active infractions of a single type to have only one active
-        infraction of that type. The infraction with the longest duration stays active.
-    """
-
-    app = "api"
-    migration_prior = "0046_reminder_jump_url"
-    migration_target = "0047_active_infractions_migration"
-
-    @classmethod
-    def setUpMigrationData(cls, apps):
-        """Sets up an initial database state that contains the relevant test cases."""
-        # Fetch the Infraction and User model in the current migration state
-        cls.infraction_model = apps.get_model('api', 'Infraction')
-        cls.user_model = apps.get_model('api', 'User')
-
-        cls.created_infractions = {}
-
-        # Moderator that serves as actor for all infractions
-        cls.user_moderator = cls.user_model.objects.create(
-            id=9999,
-            name="Olivier de Vienne",
-            discriminator=1040,
-            avatar_hash=None,
-        )
-
-        # User #1: clean user with no infractions
-        cls.created_infractions["no infractions"] = InfractionFactory.create(
-            actor=cls.user_moderator,
-            infractions=[],
-            infraction_model=cls.infraction_model,
-            user_model=cls.user_model,
-        )
-
-        # User #2: One inactive note infraction
-        cls.created_infractions["one inactive note"] = InfractionFactory.create(
-            actor=cls.user_moderator,
-            infractions=(
-                {'type': 'note', 'active': False, 'hidden': True},
-            ),
-            infraction_model=cls.infraction_model,
-            user_model=cls.user_model,
-        )
-
-        # User #3: One active note infraction
-        cls.created_infractions["one active note"] = InfractionFactory.create(
-            actor=cls.user_moderator,
-            infractions=(
-                {'type': 'note', 'active': True, 'hidden': True},
-            ),
-            infraction_model=cls.infraction_model,
-            user_model=cls.user_model,
-        )
-
-        # User #4: One active and one inactive note infraction
-        cls.created_infractions["one active and one inactive note"] = InfractionFactory.create(
-            actor=cls.user_moderator,
-            infractions=(
-                {'type': 'note', 'active': False, 'hidden': True},
-                {'type': 'note', 'active': True, 'hidden': True},
-            ),
-            infraction_model=cls.infraction_model,
-            user_model=cls.user_model,
-        )
-
-        # User #5: Once active note, one active kick, once active warning
-        cls.created_infractions["active note, kick, warning"] = InfractionFactory.create(
-            actor=cls.user_moderator,
-            infractions=(
-                {'type': 'note', 'active': True, 'hidden': True},
-                {'type': 'kick', 'active': True, 'hidden': True},
-                {'type': 'warning', 'active': True, 'hidden': True},
-            ),
-            infraction_model=cls.infraction_model,
-            user_model=cls.user_model,
-        )
-
-        # User #6: One inactive ban and one active ban
-        cls.created_infractions["one inactive and one active ban"] = InfractionFactory.create(
-            actor=cls.user_moderator,
-            infractions=(
-                {'type': 'ban', 'active': False, 'hidden': True},
-                {'type': 'ban', 'active': True, 'hidden': True},
-            ),
-            infraction_model=cls.infraction_model,
-            user_model=cls.user_model,
-        )
-
-        # User #7: Two active permanent bans
-        cls.created_infractions["two active perm bans"] = InfractionFactory.create(
-            actor=cls.user_moderator,
-            infractions=(
-                {'type': 'ban', 'active': True, 'hidden': True},
-                {'type': 'ban', 'active': True, 'hidden': True},
-            ),
-            infraction_model=cls.infraction_model,
-            user_model=cls.user_model,
-        )
-
-        # User #8: Multiple active temporary bans
-        cls.created_infractions["multiple active temp bans"] = InfractionFactory.create(
-            actor=cls.user_moderator,
-            infractions=(
-                {
-                    'type': 'ban',
-                    'active': True,
-                    'hidden': True,
-                    'expires_at': timezone.now() + timedelta(days=1)
-                },
-                {
-                    'type': 'ban',
-                    'active': True,
-                    'hidden': True,
-                    'expires_at': timezone.now() + timedelta(days=10)
-                },
-                {
-                    'type': 'ban',
-                    'active': True,
-                    'hidden': True,
-                    'expires_at': timezone.now() + timedelta(days=20)
-                },
-                {
-                    'type': 'ban',
-                    'active': True,
-                    'hidden': True,
-                    'expires_at': timezone.now() + timedelta(days=5)
-                },
-            ),
-            infraction_model=cls.infraction_model,
-            user_model=cls.user_model,
-        )
-
-        # User #9: One active permanent ban, two active temporary bans
-        cls.created_infractions["active perm, two active temp bans"] = InfractionFactory.create(
-            actor=cls.user_moderator,
-            infractions=(
-                {
-                    'type': 'ban',
-                    'active': True,
-                    'hidden': True,
-                    'expires_at': timezone.now() + timedelta(days=10)
-                },
-                {
-                    'type': 'ban',
-                    'active': True,
-                    'hidden': True,
-                    'expires_at': None,
-                },
-                {
-                    'type': 'ban',
-                    'active': True,
-                    'hidden': True,
-                    'expires_at': timezone.now() + timedelta(days=7)
-                },
-            ),
-            infraction_model=cls.infraction_model,
-            user_model=cls.user_model,
-        )
-
-        # User #10: One inactive permanent ban, two active temporary bans
-        cls.created_infractions["one inactive perm ban, two active temp bans"] = (
-            InfractionFactory.create(
-                actor=cls.user_moderator,
-                infractions=(
-                    {
-                        'type': 'ban',
-                        'active': True,
-                        'hidden': True,
-                        'expires_at': timezone.now() + timedelta(days=10)
-                    },
-                    {
-                        'type': 'ban',
-                        'active': False,
-                        'hidden': True,
-                        'expires_at': None,
-                    },
-                    {
-                        'type': 'ban',
-                        'active': True,
-                        'hidden': True,
-                        'expires_at': timezone.now() + timedelta(days=7)
-                    },
-                ),
-                infraction_model=cls.infraction_model,
-                user_model=cls.user_model,
-            )
-        )
-
-        # User #11: Active ban, active mute, active superstar
-        cls.created_infractions["active ban, mute, and superstar"] = InfractionFactory.create(
-            actor=cls.user_moderator,
-            infractions=(
-                {'type': 'ban', 'active': True, 'hidden': True},
-                {'type': 'mute', 'active': True, 'hidden': True},
-                {'type': 'superstar', 'active': True, 'hidden': True},
-                {'type': 'watch', 'active': True, 'hidden': True},
-            ),
-            infraction_model=cls.infraction_model,
-            user_model=cls.user_model,
-        )
-
-        # User #12: Multiple active bans, active mutes, active superstars
-        cls.created_infractions["multiple active bans, mutes, stars"] = InfractionFactory.create(
-            actor=cls.user_moderator,
-            infractions=(
-                {'type': 'ban', 'active': True, 'hidden': True},
-                {'type': 'ban', 'active': True, 'hidden': True},
-                {'type': 'ban', 'active': True, 'hidden': True},
-                {'type': 'mute', 'active': True, 'hidden': True},
-                {'type': 'mute', 'active': True, 'hidden': True},
-                {'type': 'mute', 'active': True, 'hidden': True},
-                {'type': 'superstar', 'active': True, 'hidden': True},
-                {'type': 'superstar', 'active': True, 'hidden': True},
-                {'type': 'superstar', 'active': True, 'hidden': True},
-                {'type': 'watch', 'active': True, 'hidden': True},
-                {'type': 'watch', 'active': True, 'hidden': True},
-                {'type': 'watch', 'active': True, 'hidden': True},
-            ),
-            infraction_model=cls.infraction_model,
-            user_model=cls.user_model,
-        )
-
-    def test_all_never_active_types_became_inactive(self):
-        """Are all infractions of a non-active type inactive after the migration?"""
-        inactive_type_query = Q(type="note") | Q(type="warning") | Q(type="kick")
-        self.assertFalse(
-            self.infraction_model.objects.filter(inactive_type_query, active=True).exists()
-        )
-
-    def test_migration_left_clean_user_without_infractions(self):
-        """Do users without infractions have no infractions after the migration?"""
-        user_id, infraction_history = self.created_infractions["no infractions"]
-        self.assertFalse(
-            self.infraction_model.objects.filter(user__id=user_id).exists()
-        )
-
-    def test_migration_left_user_with_inactive_note_untouched(self):
-        """Did the migration leave users with only an inactive note untouched?"""
-        user_id, infraction_history = self.created_infractions["one inactive note"]
-        inactive_note = infraction_history[0]
-        self.assertTrue(
-            self.infraction_model.objects.filter(**model_to_dict(inactive_note)).exists()
-        )
-
-    def test_migration_only_touched_active_field_of_active_note(self):
-        """Does the migration only change the `active` field?"""
-        user_id, infraction_history = self.created_infractions["one active note"]
-        note = model_to_dict(infraction_history[0])
-        note['active'] = False
-        self.assertTrue(
-            self.infraction_model.objects.filter(**note).exists()
-        )
-
-    def test_migration_only_touched_active_field_of_active_note_left_inactive_untouched(self):
-        """Does the migration only change the `active` field of active notes?"""
-        user_id, infraction_history = self.created_infractions["one active and one inactive note"]
-        for note in infraction_history:
-            with self.subTest(active=note.active):
-                note = model_to_dict(note)
-                note['active'] = False
-                self.assertTrue(
-                    self.infraction_model.objects.filter(**note).exists()
-                )
-
-    def test_migration_migrates_all_nonactive_types_to_inactive(self):
-        """Do we set the `active` field of all non-active infractions to `False`?"""
-        user_id, infraction_history = self.created_infractions["active note, kick, warning"]
-        self.assertFalse(
-            self.infraction_model.objects.filter(user__id=user_id, active=True).exists()
-        )
-
-    def test_migration_leaves_user_with_one_active_ban_untouched(self):
-        """Do we leave a user with one active and one inactive ban untouched?"""
-        user_id, infraction_history = self.created_infractions["one inactive and one active ban"]
-        for infraction in infraction_history:
-            with self.subTest(active=infraction.active):
-                self.assertTrue(
-                    self.infraction_model.objects.filter(**model_to_dict(infraction)).exists()
-                )
-
-    def test_migration_turns_double_active_perm_ban_into_single_active_perm_ban(self):
-        """Does the migration turn two active permanent bans into one active permanent ban?"""
-        user_id, infraction_history = self.created_infractions["two active perm bans"]
-        active_count = self.infraction_model.objects.filter(user__id=user_id, active=True).count()
-        self.assertEqual(active_count, 1)
-
-    def test_migration_leaves_temporary_ban_with_longest_duration_active(self):
-        """Does the migration turn two active permanent bans into one active permanent ban?"""
-        user_id, infraction_history = self.created_infractions["multiple active temp bans"]
-        active_ban = self.infraction_model.objects.get(user__id=user_id, active=True)
-        self.assertEqual(active_ban.expires_at, infraction_history[2].expires_at)
-
-    def test_migration_leaves_permanent_ban_active(self):
-        """Does the migration leave the permanent ban active?"""
-        user_id, infraction_history = self.created_infractions["active perm, two active temp bans"]
-        active_ban = self.infraction_model.objects.get(user__id=user_id, active=True)
-        self.assertIsNone(active_ban.expires_at)
-
-    def test_migration_leaves_longest_temp_ban_active_with_inactive_permanent_ban(self):
-        """Does the longest temp ban stay active, even with an inactive perm ban present?"""
-        user_id, infraction_history = self.created_infractions[
-            "one inactive perm ban, two active temp bans"
-        ]
-        active_ban = self.infraction_model.objects.get(user__id=user_id, active=True)
-        self.assertEqual(active_ban.expires_at, infraction_history[0].expires_at)
-
-    def test_migration_leaves_all_active_types_active_if_one_of_each_exists(self):
-        """Do all active infractions stay active if only one of each is present?"""
-        user_id, infraction_history = self.created_infractions["active ban, mute, and superstar"]
-        active_count = self.infraction_model.objects.filter(user__id=user_id, active=True).count()
-        self.assertEqual(active_count, 4)
-
-    def test_migration_reduces_all_active_types_to_a_single_active_infraction(self):
-        """Do we reduce all of the infraction types to one active infraction?"""
-        user_id, infraction_history = self.created_infractions["multiple active bans, mutes, stars"]
-        active_infractions = self.infraction_model.objects.filter(user__id=user_id, active=True)
-        self.assertEqual(len(active_infractions), 4)
-        types_observed = [infraction.type for infraction in active_infractions]
-
-        for infraction_type in ('ban', 'mute', 'superstar', 'watch'):
-            with self.subTest(type=infraction_type):
-                self.assertIn(infraction_type, types_observed)
diff --git a/pydis_site/apps/api/tests/migrations/test_base.py b/pydis_site/apps/api/tests/migrations/test_base.py
deleted file mode 100644
index f69bc92c..00000000
--- a/pydis_site/apps/api/tests/migrations/test_base.py
+++ /dev/null
@@ -1,135 +0,0 @@
-import logging
-from unittest.mock import call, patch
-
-from django.db.migrations.loader import MigrationLoader
-from django.test import TestCase
-
-from .base import MigrationsTestCase, connection
-
-log = logging.getLogger(__name__)
-
-
-class SpanishInquisition(MigrationsTestCase):
-    app = "api"
-    migration_prior = "scragly"
-    migration_target = "kosa"
-
-
-@patch("pydis_site.apps.api.tests.migrations.base.MigrationExecutor")
-class MigrationsTestCaseNoSideEffectsTests(TestCase):
-    """Tests the MigrationTestCase class with actual migration side effects disabled."""
-
-    def setUp(self):
-        """Set up an instance of MigrationsTestCase for use in tests."""
-        self.test_case = SpanishInquisition()
-
-    def test_missing_app_class_raises_value_error(self, _migration_executor):
-        """A MigrationsTestCase subclass should set the class-attribute `app`."""
-        class Spam(MigrationsTestCase):
-            pass
-
-        spam = Spam()
-        with self.assertRaises(ValueError, msg="The `app` attribute was not set."):
-            spam.setUpTestData()
-
-    def test_missing_migration_class_attributes_raise_value_error(self, _migration_executor):
-        """A MigrationsTestCase subclass should set both `migration_prior` and `migration_target`"""
-        class Eggs(MigrationsTestCase):
-            app = "api"
-            migration_target = "lemon"
-
-        class Bacon(MigrationsTestCase):
-            app = "api"
-            migration_prior = "mark"
-
-        instances = (Eggs(), Bacon())
-
-        exception_message = "Both ` migration_prior` and `migration_target` need to be set."
-        for instance in instances:
-            with self.subTest(
-                    migration_prior=instance.migration_prior,
-                    migration_target=instance.migration_target,
-            ):
-                with self.assertRaises(ValueError, msg=exception_message):
-                    instance.setUpTestData()
-
-    @patch(f"{__name__}.SpanishInquisition.setUpMigrationData")
-    @patch(f"{__name__}.SpanishInquisition.setUpPostMigrationData")
-    def test_migration_data_hooks_are_called_once(self, pre_hook, post_hook, _migration_executor):
-        """The `setUpMigrationData` and `setUpPostMigrationData` hooks should be called once."""
-        self.test_case.setUpTestData()
-        for hook in (pre_hook, post_hook):
-            with self.subTest(hook=repr(hook)):
-                hook.assert_called_once()
-
-    def test_migration_executor_is_instantiated_twice(self, migration_executor):
-        """The `MigrationExecutor` should be instantiated with the database connection twice."""
-        self.test_case.setUpTestData()
-
-        expected_args = [call(connection), call(connection)]
-        self.assertEqual(migration_executor.call_args_list, expected_args)
-
-    def test_project_state_is_loaded_for_correct_migration_files_twice(self, migration_executor):
-        """The `project_state` should first be loaded with `migrate_from`, then `migrate_to`."""
-        self.test_case.setUpTestData()
-
-        expected_args = [call(self.test_case.migrate_from), call(self.test_case.migrate_to)]
-        self.assertEqual(migration_executor().loader.project_state.call_args_list, expected_args)
-
-    def test_loader_build_graph_gets_called_once(self, migration_executor):
-        """We should rebuild the migration graph before applying the second set of migrations."""
-        self.test_case.setUpTestData()
-
-        migration_executor().loader.build_graph.assert_called_once()
-
-    def test_migration_executor_migrate_method_is_called_correctly_twice(self, migration_executor):
-        """The migrate method of the executor should be called twice with the correct arguments."""
-        self.test_case.setUpTestData()
-
-        self.assertEqual(migration_executor().migrate.call_count, 2)
-        calls = [call([('api', 'scragly')]), call([('api', 'kosa')])]
-        migration_executor().migrate.assert_has_calls(calls)
-
-
-class LifeOfBrian(MigrationsTestCase):
-    app = "api"
-    migration_prior = "0046_reminder_jump_url"
-    migration_target = "0048_add_infractions_unique_constraints_active"
-
-    @classmethod
-    def log_last_migration(cls):
-        """Parses the applied migrations dictionary to log the last applied migration."""
-        loader = MigrationLoader(connection)
-        api_migrations = [
-            migration for app, migration in loader.applied_migrations if app == cls.app
-        ]
-        last_migration = max(api_migrations, key=lambda name: int(name[:4]))
-        log.info(f"The last applied migration: {last_migration}")
-
-    @classmethod
-    def setUpMigrationData(cls, apps):
-        """Method that logs the last applied migration at this point."""
-        cls.log_last_migration()
-
-    @classmethod
-    def setUpPostMigrationData(cls, apps):
-        """Method that logs the last applied migration at this point."""
-        cls.log_last_migration()
-
-
-class MigrationsTestCaseMigrationTest(TestCase):
-    """Tests if `MigrationsTestCase` travels to the right points in the migration history."""
-
-    def test_migrations_test_case_travels_to_correct_migrations_in_history(self):
-        """The test case should first revert to `migration_prior`, then go to `migration_target`."""
-        brian = LifeOfBrian()
-
-        with self.assertLogs(log, level=logging.INFO) as logs:
-            brian.setUpTestData()
-
-            self.assertEqual(len(logs.records), 2)
-
-            for time_point, record in zip(("migration_prior", "migration_target"), logs.records):
-                with self.subTest(time_point=time_point):
-                    message = f"The last applied migration: {getattr(brian, time_point)}"
-                    self.assertEqual(record.getMessage(), message)
-- 
cgit v1.2.3
From ee25921da752d51215598bcd3eb5fd5ab74a4a46 Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Mon, 11 Jul 2022 05:00:40 +0400
Subject: Remove Message Model Test
The message model was tested by instantiating and confirming it has a
string representation, but instantiating abstract models is undefined
behavior, and can break with future versions of django.
The behavior of the test is redundant anyway, since an abstract model
wouldn't exist in isolation, and the desired behavior is confirmed by
inheritors.
Signed-off-by: Hassan Abouelela 
---
 pydis_site/apps/api/tests/test_models.py | 12 ------------
 1 file changed, 12 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index 0fad467c..c07d59cd 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -7,7 +7,6 @@ from pydis_site.apps.api.models import (
     DeletedMessage,
     DocumentationLink,
     Infraction,
-    Message,
     MessageDeletionContext,
     Nomination,
     NominationEntry,
@@ -116,17 +115,6 @@ class StringDunderMethodTests(SimpleTestCase):
                 colour=0x5, permissions=0,
                 position=10,
             ),
-            Message(
-                id=45,
-                author=User(
-                    id=444,
-                    name='bill',
-                    discriminator=5,
-                ),
-                channel_id=666,
-                content="wooey",
-                embeds=[]
-            ),
             MessageDeletionContext(
                 actor=User(
                     id=5555,
-- 
cgit v1.2.3
From 36a461b57c5b901fb5cfb81966bac3f0387fd590 Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Mon, 11 Jul 2022 05:05:51 +0400
Subject: Bump flake8-bandit To v3
Bumps flake-bandit to v3 to fix an incompatibility with the bandit
package.
This also bumps flake8-annotations to a legally acceptable version,
which introduces ANN401, which disallows `typing.Any` annotations (for
the most part, refer to the docs).
Signed-off-by: Hassan Abouelela 
---
 poetry.lock                                    | 271 +++++++++++--------------
 pydis_site/apps/api/pagination.py              |   5 +-
 pydis_site/apps/content/views/page_category.py |   4 +-
 pyproject.toml                                 |   2 +-
 4 files changed, 120 insertions(+), 162 deletions(-)
(limited to 'pydis_site')
diff --git a/poetry.lock b/poetry.lock
index fcdc9d84..826c4fca 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -17,7 +17,7 @@ trio = ["trio (>=0.16)"]
 
 [[package]]
 name = "asgiref"
-version = "3.5.0"
+version = "3.5.2"
 description = "ASGI specs, helper code, and adapters"
 category = "main"
 optional = false
@@ -42,7 +42,7 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>
 
 [[package]]
 name = "bandit"
-version = "1.7.2"
+version = "1.7.4"
 description = "Security oriented static analyser for python code."
 category = "dev"
 optional = false
@@ -55,17 +55,17 @@ PyYAML = ">=5.3.1"
 stevedore = ">=1.20.0"
 
 [package.extras]
-test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml"]
+test = ["coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml", "beautifulsoup4 (>=4.8.0)", "pylint (==1.9.4)"]
 toml = ["toml"]
 yaml = ["pyyaml"]
 
 [[package]]
 name = "certifi"
-version = "2021.10.8"
+version = "2022.6.15"
 description = "Python package for providing Mozilla's CA Bundle."
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6"
 
 [[package]]
 name = "cfgv"
@@ -77,18 +77,18 @@ python-versions = ">=3.6.1"
 
 [[package]]
 name = "charset-normalizer"
-version = "2.0.11"
+version = "2.1.0"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 category = "main"
 optional = false
-python-versions = ">=3.5.0"
+python-versions = ">=3.6.0"
 
 [package.extras]
 unicode_backport = ["unicodedata2"]
 
 [[package]]
 name = "colorama"
-version = "0.4.4"
+version = "0.4.5"
 description = "Cross-platform colored terminal text."
 category = "dev"
 optional = false
@@ -190,7 +190,7 @@ prometheus-client = ">=0.7"
 
 [[package]]
 name = "django-simple-bulma"
-version = "2.4.0"
+version = "2.5.0"
 description = "Django application to add the Bulma CSS framework and its extensions"
 category = "main"
 optional = false
@@ -224,7 +224,7 @@ python-versions = "*"
 
 [[package]]
 name = "filelock"
-version = "3.4.2"
+version = "3.7.1"
 description = "A platform independent file lock."
 category = "dev"
 optional = false
@@ -249,25 +249,26 @@ pyflakes = ">=2.3.0,<2.4.0"
 
 [[package]]
 name = "flake8-annotations"
-version = "2.7.0"
+version = "2.9.0"
 description = "Flake8 Type Annotation Checks"
 category = "dev"
 optional = false
-python-versions = ">=3.6.2,<4.0.0"
+python-versions = ">=3.7,<4.0"
 
 [package.dependencies]
-flake8 = ">=3.7,<5.0"
+attrs = ">=21.4,<22.0"
+flake8 = ">=3.7"
 
 [[package]]
 name = "flake8-bandit"
-version = "2.1.2"
+version = "3.0.0"
 description = "Automated security testing with bandit and flake8."
 category = "dev"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6"
 
 [package.dependencies]
-bandit = "*"
+bandit = ">=1.7.3"
 flake8 = "*"
 flake8-polyfill = "*"
 pycodestyle = "*"
@@ -334,7 +335,7 @@ flake8 = "*"
 
 [[package]]
 name = "flake8-tidy-imports"
-version = "4.6.0"
+version = "4.8.0"
 description = "A flake8 plugin that helps you write tidier imports."
 category = "dev"
 optional = false
@@ -367,7 +368,7 @@ smmap = ">=3.0.1,<6"
 
 [[package]]
 name = "gitpython"
-version = "3.1.26"
+version = "3.1.27"
 description = "GitPython is a python library used to interact with Git repositories"
 category = "dev"
 optional = false
@@ -438,7 +439,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"]
 
 [[package]]
 name = "identify"
-version = "2.4.6"
+version = "2.5.1"
 description = "File identification library for Python"
 category = "dev"
 optional = false
@@ -457,7 +458,7 @@ python-versions = ">=3.5"
 
 [[package]]
 name = "importlib-metadata"
-version = "4.10.1"
+version = "4.12.0"
 description = "Read metadata from Python packages"
 category = "main"
 optional = false
@@ -467,9 +468,9 @@ python-versions = ">=3.7"
 zipp = ">=0.5"
 
 [package.extras]
-docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
 perf = ["ipython"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
 
 [[package]]
 name = "libsass"
@@ -484,7 +485,7 @@ six = "*"
 
 [[package]]
 name = "markdown"
-version = "3.3.6"
+version = "3.3.7"
 description = "Python implementation of Markdown."
 category = "main"
 optional = false
@@ -514,15 +515,15 @@ python-versions = ">=3.5"
 
 [[package]]
 name = "nodeenv"
-version = "1.6.0"
+version = "1.7.0"
 description = "Node.js virtual environment builder"
 category = "dev"
 optional = false
-python-versions = "*"
+python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
 
 [[package]]
 name = "pbr"
-version = "5.8.0"
+version = "5.9.0"
 description = "Python Build Reasonableness"
 category = "dev"
 optional = false
@@ -530,7 +531,7 @@ python-versions = ">=2.6"
 
 [[package]]
 name = "pep8-naming"
-version = "0.12.1"
+version = "0.13.0"
 description = "Check PEP-8 naming conventions, plugin for flake8"
 category = "dev"
 optional = false
@@ -538,27 +539,26 @@ python-versions = "*"
 
 [package.dependencies]
 flake8 = ">=3.9.1"
-flake8-polyfill = ">=1.0.2,<2"
 
 [[package]]
 name = "platformdirs"
-version = "2.4.1"
+version = "2.5.2"
 description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 
 [package.extras]
-docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"]
-test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"]
+docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
+test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
 
 [[package]]
 name = "pre-commit"
-version = "2.17.0"
+version = "2.19.0"
 description = "A framework for managing and maintaining multi-language pre-commit hooks."
 category = "dev"
 optional = false
-python-versions = ">=3.6.1"
+python-versions = ">=3.7"
 
 [package.dependencies]
 cfgv = ">=2.0.0"
@@ -570,7 +570,7 @@ virtualenv = ">=20.0.8"
 
 [[package]]
 name = "prometheus-client"
-version = "0.13.1"
+version = "0.14.1"
 description = "Python client for the Prometheus monitoring system."
 category = "main"
 optional = false
@@ -581,14 +581,14 @@ twisted = ["twisted"]
 
 [[package]]
 name = "psutil"
-version = "5.9.0"
+version = "5.9.1"
 description = "Cross-platform lib for process and system monitoring in Python."
 category = "dev"
 optional = false
-python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [package.extras]
-test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"]
+test = ["ipaddress", "mock", "enum34", "pywin32", "wmi"]
 
 [[package]]
 name = "psycopg2-binary"
@@ -622,7 +622,7 @@ toml = ["toml"]
 
 [[package]]
 name = "pyfakefs"
-version = "4.5.4"
+version = "4.5.6"
 description = "pyfakefs implements a fake file system that mocks the Python file system modules."
 category = "dev"
 optional = false
@@ -664,7 +664,7 @@ test = ["pytest", "toml", "pyaml"]
 
 [[package]]
 name = "pytz"
-version = "2021.3"
+version = "2022.1"
 description = "World timezone definitions, modern and historical"
 category = "main"
 optional = false
@@ -812,20 +812,20 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "urllib3"
-version = "1.26.8"
+version = "1.26.10"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4"
 
 [package.extras]
-brotli = ["brotlipy (>=0.6.0)"]
+brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
 secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
 socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
 
 [[package]]
 name = "virtualenv"
-version = "20.13.0"
+version = "20.15.1"
 description = "Virtual Python Environment builder"
 category = "dev"
 optional = false
@@ -854,20 +854,20 @@ brotli = ["brotli"]
 
 [[package]]
 name = "zipp"
-version = "3.7.0"
+version = "3.8.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 category = "main"
 optional = false
 python-versions = ">=3.7"
 
 [package.extras]
-docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
+docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
 
 [metadata]
 lock-version = "1.1"
 python-versions = "3.9.*"
-content-hash = "c94949e29f868689d9c99379dbf4f9479e2ddfe5e6d49e15b57d016210a50379"
+content-hash = "91913e2e96ab2e0e78a09334241062359605135d64f458e710f66d00fb670e05"
 
 [metadata.files]
 anyio = [
@@ -875,32 +875,26 @@ anyio = [
     {file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"},
 ]
 asgiref = [
-    {file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"},
-    {file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"},
+    {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"},
+    {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"},
 ]
 attrs = [
     {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
     {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
 ]
-bandit = [
-    {file = "bandit-1.7.2-py3-none-any.whl", hash = "sha256:e20402cadfd126d85b68ed4c8862959663c8c372dbbb1fca8f8e2c9f55a067ec"},
-    {file = "bandit-1.7.2.tar.gz", hash = "sha256:6d11adea0214a43813887bfe71a377b5a9955e4c826c8ffd341b494e3ab25260"},
-]
+bandit = []
 certifi = [
-    {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
-    {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
+    {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"},
+    {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"},
 ]
 cfgv = [
     {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"},
     {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
 ]
-charset-normalizer = [
-    {file = "charset-normalizer-2.0.11.tar.gz", hash = "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c"},
-    {file = "charset_normalizer-2.0.11-py3-none-any.whl", hash = "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45"},
-]
+charset-normalizer = []
 colorama = [
-    {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
-    {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
+    {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
+    {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
 ]
 coverage = [
     {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"},
@@ -983,10 +977,7 @@ django-prometheus = [
     {file = "django-prometheus-2.2.0.tar.gz", hash = "sha256:240378a1307c408bd5fc85614a3a57f1ce633d4a222c9e291e2bbf325173b801"},
     {file = "django_prometheus-2.2.0-py2.py3-none-any.whl", hash = "sha256:e6616770d8820b8834762764bf1b76ec08e1b98e72a6f359d488a2e15fe3537c"},
 ]
-django-simple-bulma = [
-    {file = "django-simple-bulma-2.4.0.tar.gz", hash = "sha256:99a15261b0c61062a128af3c6a45da9c066d6a4a548c9063464e0fb7a5438aa1"},
-    {file = "django_simple_bulma-2.4.0-py3-none-any.whl", hash = "sha256:95d5e26bebbf6a0184e33df844a0ff534bdfd91431e413d1a844d47a75c55fff"},
-]
+django-simple-bulma = []
 djangorestframework = [
     {file = "djangorestframework-3.12.4-py3-none-any.whl", hash = "sha256:6d1d59f623a5ad0509fe0d6bfe93cbdfe17b8116ebc8eda86d45f6e16e819aaf"},
     {file = "djangorestframework-3.12.4.tar.gz", hash = "sha256:f747949a8ddac876e879190df194b925c177cdeb725a099db1460872f7c0a7f2"},
@@ -994,21 +985,16 @@ djangorestframework = [
 docopt = [
     {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
 ]
-filelock = [
-    {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"},
-    {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"},
-]
+filelock = []
 flake8 = [
     {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"},
     {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"},
 ]
 flake8-annotations = [
-    {file = "flake8-annotations-2.7.0.tar.gz", hash = "sha256:52e53c05b0c06cac1c2dec192ea2c36e85081238add3bd99421d56f574b9479b"},
-    {file = "flake8_annotations-2.7.0-py3-none-any.whl", hash = "sha256:3edfbbfb58e404868834fe6ec3eaf49c139f64f0701259f707d043185545151e"},
-]
-flake8-bandit = [
-    {file = "flake8_bandit-2.1.2.tar.gz", hash = "sha256:687fc8da2e4a239b206af2e54a90093572a60d0954f3054e23690739b0b0de3b"},
+    {file = "flake8-annotations-2.9.0.tar.gz", hash = "sha256:63fb3f538970b6a8dfd84125cf5af16f7b22e52d5032acb3b7eb23645ecbda9b"},
+    {file = "flake8_annotations-2.9.0-py3-none-any.whl", hash = "sha256:84f46de2964cb18fccea968d9eafce7cf857e34d913d515120795b9af6498d56"},
 ]
+flake8-bandit = []
 flake8-bugbear = [
     {file = "flake8-bugbear-20.11.1.tar.gz", hash = "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538"},
     {file = "flake8_bugbear-20.11.1-py36.py37.py38-none-any.whl", hash = "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703"},
@@ -1030,8 +1016,8 @@ flake8-string-format = [
     {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"},
 ]
 flake8-tidy-imports = [
-    {file = "flake8-tidy-imports-4.6.0.tar.gz", hash = "sha256:3e193d8c4bb4492408a90e956d888b27eed14c698387c9b38230da3dad78058f"},
-    {file = "flake8_tidy_imports-4.6.0-py3-none-any.whl", hash = "sha256:6ae9f55d628156e19d19f4c359dd5d3e95431a9bd514f5e2748c53c1398c66b2"},
+    {file = "flake8-tidy-imports-4.8.0.tar.gz", hash = "sha256:df44f9c841b5dfb3a7a1f0da8546b319d772c2a816a1afefcce43e167a593d83"},
+    {file = "flake8_tidy_imports-4.8.0-py3-none-any.whl", hash = "sha256:25bd9799358edefa0e010ce2c587b093c3aba942e96aeaa99b6d0500ae1bf09c"},
 ]
 flake8-todo = [
     {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"},
@@ -1041,8 +1027,8 @@ gitdb = [
     {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"},
 ]
 gitpython = [
-    {file = "GitPython-3.1.26-py3-none-any.whl", hash = "sha256:26ac35c212d1f7b16036361ca5cff3ec66e11753a0d677fb6c48fa4e1a9dd8d6"},
-    {file = "GitPython-3.1.26.tar.gz", hash = "sha256:fc8868f63a2e6d268fb25f481995ba185a85a66fcad126f039323ff6635669ee"},
+    {file = "GitPython-3.1.27-py3-none-any.whl", hash = "sha256:5b68b000463593e05ff2b261acff0ff0972df8ab1b70d3cdbd41b546c8b8fc3d"},
+    {file = "GitPython-3.1.27.tar.gz", hash = "sha256:1c885ce809e8ba2d88a29befeb385fcea06338d3640712b59ca623c220bb5704"},
 ]
 gunicorn = [
     {file = "gunicorn-20.0.4-py2.py3-none-any.whl", hash = "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"},
@@ -1055,17 +1041,14 @@ h11 = [
 httpcore = []
 httpx = []
 identify = [
-    {file = "identify-2.4.6-py2.py3-none-any.whl", hash = "sha256:cf06b1639e0dca0c184b1504d8b73448c99a68e004a80524c7923b95f7b6837c"},
-    {file = "identify-2.4.6.tar.gz", hash = "sha256:233679e3f61a02015d4293dbccf16aa0e4996f868bd114688b8c124f18826706"},
+    {file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"},
+    {file = "identify-2.5.1.tar.gz", hash = "sha256:3d11b16f3fe19f52039fb7e39c9c884b21cb1b586988114fbe42671f03de3e82"},
 ]
 idna = [
     {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
     {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
 ]
-importlib-metadata = [
-    {file = "importlib_metadata-4.10.1-py3-none-any.whl", hash = "sha256:899e2a40a8c4a1aec681feef45733de8a6c58f3f6a0dbed2eb6574b4387a77b6"},
-    {file = "importlib_metadata-4.10.1.tar.gz", hash = "sha256:951f0d8a5b7260e9db5e41d429285b5f451e928479f19d80818878527d36e95e"},
-]
+importlib-metadata = []
 libsass = [
     {file = "libsass-0.21.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:06c8776417fe930714bdc930a3d7e795ae3d72be6ac883ff72a1b8f7c49e5ffb"},
     {file = "libsass-0.21.0-cp27-cp27m-win32.whl", hash = "sha256:a005f298f64624f313a3ac618ab03f844c71d84ae4f4a4aec4b68d2a4ffe75eb"},
@@ -1078,10 +1061,7 @@ libsass = [
     {file = "libsass-0.21.0-cp38-abi3-macosx_12_0_arm64.whl", hash = "sha256:c9ec490609752c1d81ff6290da33485aa7cb6d7365ac665b74464c1b7d97f7da"},
     {file = "libsass-0.21.0.tar.gz", hash = "sha256:d5ba529d9ce668be9380563279f3ffe988f27bc5b299c5a28453df2e0b0fbaf2"},
 ]
-markdown = [
-    {file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"},
-    {file = "Markdown-3.3.6.tar.gz", hash = "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006"},
-]
+markdown = []
 mccabe = [
     {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
     {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
@@ -1090,63 +1070,51 @@ mslex = [
     {file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"},
     {file = "mslex-0.3.0.tar.gz", hash = "sha256:4a1ac3f25025cad78ad2fe499dd16d42759f7a3801645399cce5c404415daa97"},
 ]
-nodeenv = [
-    {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
-    {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
-]
-pbr = [
-    {file = "pbr-5.8.0-py2.py3-none-any.whl", hash = "sha256:176e8560eaf61e127817ef93d8a844803abb27a4d4637f0ff3bb783129be2e0a"},
-    {file = "pbr-5.8.0.tar.gz", hash = "sha256:672d8ebee84921862110f23fcec2acea191ef58543d34dfe9ef3d9f13c31cddf"},
-]
-pep8-naming = [
-    {file = "pep8-naming-0.12.1.tar.gz", hash = "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"},
-    {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"},
-]
+nodeenv = []
+pbr = []
+pep8-naming = []
 platformdirs = [
-    {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"},
-    {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"},
+    {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
+    {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
 ]
 pre-commit = [
-    {file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"},
-    {file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"},
-]
-prometheus-client = [
-    {file = "prometheus_client-0.13.1-py3-none-any.whl", hash = "sha256:357a447fd2359b0a1d2e9b311a0c5778c330cfbe186d880ad5a6b39884652316"},
-    {file = "prometheus_client-0.13.1.tar.gz", hash = "sha256:ada41b891b79fca5638bd5cfe149efa86512eaa55987893becd2c6d8d0a5dfc5"},
+    {file = "pre_commit-2.19.0-py2.py3-none-any.whl", hash = "sha256:10c62741aa5704faea2ad69cb550ca78082efe5697d6f04e5710c3c229afdd10"},
+    {file = "pre_commit-2.19.0.tar.gz", hash = "sha256:4233a1e38621c87d9dda9808c6606d7e7ba0e087cd56d3fe03202a01d2919615"},
 ]
+prometheus-client = []
 psutil = [
-    {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:55ce319452e3d139e25d6c3f85a1acf12d1607ddedea5e35fb47a552c051161b"},
-    {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:7336292a13a80eb93c21f36bde4328aa748a04b68c13d01dfddd67fc13fd0618"},
-    {file = "psutil-5.9.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:cb8d10461c1ceee0c25a64f2dd54872b70b89c26419e147a05a10b753ad36ec2"},
-    {file = "psutil-5.9.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:7641300de73e4909e5d148e90cc3142fb890079e1525a840cf0dfd39195239fd"},
-    {file = "psutil-5.9.0-cp27-none-win32.whl", hash = "sha256:ea42d747c5f71b5ccaa6897b216a7dadb9f52c72a0fe2b872ef7d3e1eacf3ba3"},
-    {file = "psutil-5.9.0-cp27-none-win_amd64.whl", hash = "sha256:ef216cc9feb60634bda2f341a9559ac594e2eeaadd0ba187a4c2eb5b5d40b91c"},
-    {file = "psutil-5.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90a58b9fcae2dbfe4ba852b57bd4a1dded6b990a33d6428c7614b7d48eccb492"},
-    {file = "psutil-5.9.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d41f8b3e9ebb6b6110057e40019a432e96aae2008951121ba4e56040b84f3"},
-    {file = "psutil-5.9.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:742c34fff804f34f62659279ed5c5b723bb0195e9d7bd9907591de9f8f6558e2"},
-    {file = "psutil-5.9.0-cp310-cp310-win32.whl", hash = "sha256:8293942e4ce0c5689821f65ce6522ce4786d02af57f13c0195b40e1edb1db61d"},
-    {file = "psutil-5.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9b51917c1af3fa35a3f2dabd7ba96a2a4f19df3dec911da73875e1edaf22a40b"},
-    {file = "psutil-5.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e9805fed4f2a81de98ae5fe38b75a74c6e6ad2df8a5c479594c7629a1fe35f56"},
-    {file = "psutil-5.9.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c51f1af02334e4b516ec221ee26b8fdf105032418ca5a5ab9737e8c87dafe203"},
-    {file = "psutil-5.9.0-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32acf55cb9a8cbfb29167cd005951df81b567099295291bcfd1027365b36591d"},
-    {file = "psutil-5.9.0-cp36-cp36m-win32.whl", hash = "sha256:e5c783d0b1ad6ca8a5d3e7b680468c9c926b804be83a3a8e95141b05c39c9f64"},
-    {file = "psutil-5.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d62a2796e08dd024b8179bd441cb714e0f81226c352c802fca0fd3f89eeacd94"},
-    {file = "psutil-5.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d00a664e31921009a84367266b35ba0aac04a2a6cad09c550a89041034d19a0"},
-    {file = "psutil-5.9.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7779be4025c540d1d65a2de3f30caeacc49ae7a2152108adeaf42c7534a115ce"},
-    {file = "psutil-5.9.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:072664401ae6e7c1bfb878c65d7282d4b4391f1bc9a56d5e03b5a490403271b5"},
-    {file = "psutil-5.9.0-cp37-cp37m-win32.whl", hash = "sha256:df2c8bd48fb83a8408c8390b143c6a6fa10cb1a674ca664954de193fdcab36a9"},
-    {file = "psutil-5.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1d7b433519b9a38192dfda962dd8f44446668c009833e1429a52424624f408b4"},
-    {file = "psutil-5.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c3400cae15bdb449d518545cbd5b649117de54e3596ded84aacabfbb3297ead2"},
-    {file = "psutil-5.9.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2237f35c4bbae932ee98902a08050a27821f8f6dfa880a47195e5993af4702d"},
-    {file = "psutil-5.9.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1070a9b287846a21a5d572d6dddd369517510b68710fca56b0e9e02fd24bed9a"},
-    {file = "psutil-5.9.0-cp38-cp38-win32.whl", hash = "sha256:76cebf84aac1d6da5b63df11fe0d377b46b7b500d892284068bacccf12f20666"},
-    {file = "psutil-5.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:3151a58f0fbd8942ba94f7c31c7e6b310d2989f4da74fcbf28b934374e9bf841"},
-    {file = "psutil-5.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:539e429da49c5d27d5a58e3563886057f8fc3868a5547b4f1876d9c0f007bccf"},
-    {file = "psutil-5.9.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58c7d923dc209225600aec73aa2c4ae8ea33b1ab31bc11ef8a5933b027476f07"},
-    {file = "psutil-5.9.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3611e87eea393f779a35b192b46a164b1d01167c9d323dda9b1e527ea69d697d"},
-    {file = "psutil-5.9.0-cp39-cp39-win32.whl", hash = "sha256:4e2fb92e3aeae3ec3b7b66c528981fd327fb93fd906a77215200404444ec1845"},
-    {file = "psutil-5.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:7d190ee2eaef7831163f254dc58f6d2e2a22e27382b936aab51c835fc080c3d3"},
-    {file = "psutil-5.9.0.tar.gz", hash = "sha256:869842dbd66bb80c3217158e629d6fceaecc3a3166d3d1faee515b05dd26ca25"},
+    {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:799759d809c31aab5fe4579e50addf84565e71c1dc9f1c31258f159ff70d3f87"},
+    {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9272167b5f5fbfe16945be3db475b3ce8d792386907e673a209da686176552af"},
+    {file = "psutil-5.9.1-cp27-cp27m-win32.whl", hash = "sha256:0904727e0b0a038830b019551cf3204dd48ef5c6868adc776e06e93d615fc5fc"},
+    {file = "psutil-5.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e7e10454cb1ab62cc6ce776e1c135a64045a11ec4c6d254d3f7689c16eb3efd2"},
+    {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:56960b9e8edcca1456f8c86a196f0c3d8e3e361320071c93378d41445ffd28b0"},
+    {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:44d1826150d49ffd62035785a9e2c56afcea66e55b43b8b630d7706276e87f22"},
+    {file = "psutil-5.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7be9d7f5b0d206f0bbc3794b8e16fb7dbc53ec9e40bbe8787c6f2d38efcf6c9"},
+    {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd9246e4cdd5b554a2ddd97c157e292ac11ef3e7af25ac56b08b455c829dca8"},
+    {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29a442e25fab1f4d05e2655bb1b8ab6887981838d22effa2396d584b740194de"},
+    {file = "psutil-5.9.1-cp310-cp310-win32.whl", hash = "sha256:20b27771b077dcaa0de1de3ad52d22538fe101f9946d6dc7869e6f694f079329"},
+    {file = "psutil-5.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:58678bbadae12e0db55186dc58f2888839228ac9f41cc7848853539b70490021"},
+    {file = "psutil-5.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a76ad658641172d9c6e593de6fe248ddde825b5866464c3b2ee26c35da9d237"},
+    {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6a11e48cb93a5fa606306493f439b4aa7c56cb03fc9ace7f6bfa21aaf07c453"},
+    {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068935df39055bf27a29824b95c801c7a5130f118b806eee663cad28dca97685"},
+    {file = "psutil-5.9.1-cp36-cp36m-win32.whl", hash = "sha256:0f15a19a05f39a09327345bc279c1ba4a8cfb0172cc0d3c7f7d16c813b2e7d36"},
+    {file = "psutil-5.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:db417f0865f90bdc07fa30e1aadc69b6f4cad7f86324b02aa842034efe8d8c4d"},
+    {file = "psutil-5.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:91c7ff2a40c373d0cc9121d54bc5f31c4fa09c346528e6a08d1845bce5771ffc"},
+    {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fea896b54f3a4ae6f790ac1d017101252c93f6fe075d0e7571543510f11d2676"},
+    {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3054e923204b8e9c23a55b23b6df73a8089ae1d075cb0bf711d3e9da1724ded4"},
+    {file = "psutil-5.9.1-cp37-cp37m-win32.whl", hash = "sha256:d2d006286fbcb60f0b391741f520862e9b69f4019b4d738a2a45728c7e952f1b"},
+    {file = "psutil-5.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b14ee12da9338f5e5b3a3ef7ca58b3cba30f5b66f7662159762932e6d0b8f680"},
+    {file = "psutil-5.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:19f36c16012ba9cfc742604df189f2f28d2720e23ff7d1e81602dbe066be9fd1"},
+    {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:944c4b4b82dc4a1b805329c980f270f170fdc9945464223f2ec8e57563139cf4"},
+    {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b6750a73a9c4a4e689490ccb862d53c7b976a2a35c4e1846d049dcc3f17d83b"},
+    {file = "psutil-5.9.1-cp38-cp38-win32.whl", hash = "sha256:a8746bfe4e8f659528c5c7e9af5090c5a7d252f32b2e859c584ef7d8efb1e689"},
+    {file = "psutil-5.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:79c9108d9aa7fa6fba6e668b61b82facc067a6b81517cab34d07a84aa89f3df0"},
+    {file = "psutil-5.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28976df6c64ddd6320d281128817f32c29b539a52bdae5e192537bc338a9ec81"},
+    {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b88f75005586131276634027f4219d06e0561292be8bd6bc7f2f00bdabd63c4e"},
+    {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:645bd4f7bb5b8633803e0b6746ff1628724668681a434482546887d22c7a9537"},
+    {file = "psutil-5.9.1-cp39-cp39-win32.whl", hash = "sha256:32c52611756096ae91f5d1499fe6c53b86f4a9ada147ee42db4991ba1520e574"},
+    {file = "psutil-5.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:f65f9a46d984b8cd9b3750c2bdb419b2996895b005aefa6cbaba9a143b1ce2c5"},
+    {file = "psutil-5.9.1.tar.gz", hash = "sha256:57f1819b5d9e95cdfb0c881a8a5b7d542ed0b7c522d575706a80bedc848c8954"},
 ]
 psycopg2-binary = [
     {file = "psycopg2-binary-2.8.6.tar.gz", hash = "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0"},
@@ -1193,10 +1161,7 @@ pydocstyle = [
     {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"},
     {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"},
 ]
-pyfakefs = [
-    {file = "pyfakefs-4.5.4-py3-none-any.whl", hash = "sha256:e0cc0d22cb74badf4fb2143a112817d7aea1a58ee9dca015a68bf38c3691cb52"},
-    {file = "pyfakefs-4.5.4.tar.gz", hash = "sha256:5b5951e873f73bf12e3a19d8e4470c4b7962c51df753cf8c4caaf64e24a0a323"},
-]
+pyfakefs = []
 pyflakes = [
     {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"},
     {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
@@ -1210,8 +1175,8 @@ python-frontmatter = [
     {file = "python_frontmatter-1.0.0-py3-none-any.whl", hash = "sha256:766ae75f1b301ffc5fe3494339147e0fd80bc3deff3d7590a93991978b579b08"},
 ]
 pytz = [
-    {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"},
-    {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"},
+    {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"},
+    {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"},
 ]
 pyyaml = [
     {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
@@ -1285,19 +1250,13 @@ toml = [
     {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
-urllib3 = [
-    {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"},
-    {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"},
-]
-virtualenv = [
-    {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"},
-    {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"},
-]
+urllib3 = []
+virtualenv = []
 whitenoise = [
     {file = "whitenoise-5.3.0-py2.py3-none-any.whl", hash = "sha256:d963ef25639d1417e8a247be36e6aedd8c7c6f0a08adcb5a89146980a96b577c"},
     {file = "whitenoise-5.3.0.tar.gz", hash = "sha256:d234b871b52271ae7ed6d9da47ffe857c76568f11dd30e28e18c5869dbd11e12"},
 ]
 zipp = [
-    {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"},
-    {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"},
+    {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"},
+    {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"},
 ]
diff --git a/pydis_site/apps/api/pagination.py b/pydis_site/apps/api/pagination.py
index 2a325460..61707d33 100644
--- a/pydis_site/apps/api/pagination.py
+++ b/pydis_site/apps/api/pagination.py
@@ -1,7 +1,6 @@
-import typing
-
 from rest_framework.pagination import LimitOffsetPagination
 from rest_framework.response import Response
+from rest_framework.utils.serializer_helpers import ReturnList
 
 
 class LimitOffsetPaginationExtended(LimitOffsetPagination):
@@ -44,6 +43,6 @@ class LimitOffsetPaginationExtended(LimitOffsetPagination):
 
     default_limit = 100
 
-    def get_paginated_response(self, data: typing.Any) -> Response:
+    def get_paginated_response(self, data: ReturnList) -> Response:
         """Override to skip metadata i.e. `count`, `next`, and `previous`."""
         return Response(data)
diff --git a/pydis_site/apps/content/views/page_category.py b/pydis_site/apps/content/views/page_category.py
index 5af77aff..356eb021 100644
--- a/pydis_site/apps/content/views/page_category.py
+++ b/pydis_site/apps/content/views/page_category.py
@@ -3,7 +3,7 @@ from pathlib import Path
 
 import frontmatter
 from django.conf import settings
-from django.http import Http404
+from django.http import Http404, HttpRequest, HttpResponse
 from django.views.generic import TemplateView
 
 from pydis_site.apps.content import utils
@@ -12,7 +12,7 @@ from pydis_site.apps.content import utils
 class PageOrCategoryView(TemplateView):
     """Handles pages and page categories."""
 
-    def dispatch(self, request: t.Any, *args, **kwargs) -> t.Any:
+    def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
         """Conform URL path location to the filesystem path."""
         self.location = Path(kwargs.get("location", ""))
 
diff --git a/pyproject.toml b/pyproject.toml
index 01e7eda2..6ef7c407 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,7 +27,7 @@ django-distill = "~=2.9.0"
 coverage = "~=5.0"
 flake8 = "~=3.7"
 flake8-annotations = "~=2.0"
-flake8-bandit = "~=2.1"
+flake8-bandit = "~=3.0"
 flake8-bugbear = "~=20.1"
 flake8-docstrings = "~=1.5"
 flake8-import-order = "~=0.18"
-- 
cgit v1.2.3
From 4edb368c39c96f20e41a8874b0f77f30910c8ce7 Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Mon, 11 Jul 2022 05:10:19 +0400
Subject: Bump Django To 3.2
Bumps the current django version to 3.2 For the most part, there are no
major changes here. The app configuration names were incorrect, and the
new version no longer ignores that, so those were updated. The new
version also requires explicitly defining the field type for primary
keys if they are not defined on the model.
Signed-off-by: Hassan Abouelela 
---
 poetry.lock                       | 13 +++++--------
 pydis_site/apps/content/apps.py   |  2 +-
 pydis_site/apps/events/apps.py    |  2 +-
 pydis_site/apps/redirect/apps.py  |  2 +-
 pydis_site/apps/resources/apps.py |  2 +-
 pydis_site/apps/staff/apps.py     |  2 +-
 pydis_site/settings.py            |  3 +++
 pyproject.toml                    |  2 +-
 8 files changed, 14 insertions(+), 14 deletions(-)
(limited to 'pydis_site')
diff --git a/poetry.lock b/poetry.lock
index 826c4fca..d855bdaf 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -131,19 +131,19 @@ python-versions = "*"
 
 [[package]]
 name = "django"
-version = "3.1.14"
+version = "3.2.14"
 description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
 category = "main"
 optional = false
 python-versions = ">=3.6"
 
 [package.dependencies]
-asgiref = ">=3.2.10,<4"
+asgiref = ">=3.3.2,<4"
 pytz = "*"
 sqlparse = ">=0.2.2"
 
 [package.extras]
-argon2 = ["argon2-cffi (>=16.1.0)"]
+argon2 = ["argon2-cffi (>=19.1.0)"]
 bcrypt = ["bcrypt"]
 
 [[package]]
@@ -867,7 +867,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-
 [metadata]
 lock-version = "1.1"
 python-versions = "3.9.*"
-content-hash = "91913e2e96ab2e0e78a09334241062359605135d64f458e710f66d00fb670e05"
+content-hash = "e40d87f94732c314e0fb3cd2c9023ea47d66d3c3d340085eb6df9c1fe1412529"
 
 [metadata.files]
 anyio = [
@@ -958,10 +958,7 @@ distlib = [
     {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"},
     {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"},
 ]
-django = [
-    {file = "Django-3.1.14-py3-none-any.whl", hash = "sha256:0fabc786489af16ad87a8c170ba9d42bfd23f7b699bd5ef05675864e8d012859"},
-    {file = "Django-3.1.14.tar.gz", hash = "sha256:72a4a5a136a214c39cf016ccdd6b69e2aa08c7479c66d93f3a9b5e4bb9d8a347"},
-]
+django = []
 django-distill = [
     {file = "django-distill-2.9.2.tar.gz", hash = "sha256:91d5f45c2ff78b8efd4805ff5ec17df4ba815bbf51ca12a2cea65727d2f1d42e"},
 ]
diff --git a/pydis_site/apps/content/apps.py b/pydis_site/apps/content/apps.py
index 1e300a48..96019e1c 100644
--- a/pydis_site/apps/content/apps.py
+++ b/pydis_site/apps/content/apps.py
@@ -4,4 +4,4 @@ from django.apps import AppConfig
 class ContentConfig(AppConfig):
     """Django AppConfig for content app."""
 
-    name = 'content'
+    name = 'pydis_site.apps.content'
diff --git a/pydis_site/apps/events/apps.py b/pydis_site/apps/events/apps.py
index a1cf09ef..70762bc2 100644
--- a/pydis_site/apps/events/apps.py
+++ b/pydis_site/apps/events/apps.py
@@ -4,4 +4,4 @@ from django.apps import AppConfig
 class EventsConfig(AppConfig):
     """Django AppConfig for events app."""
 
-    name = 'events'
+    name = 'pydis_site.apps.events'
diff --git a/pydis_site/apps/redirect/apps.py b/pydis_site/apps/redirect/apps.py
index 9b70d169..0234bc93 100644
--- a/pydis_site/apps/redirect/apps.py
+++ b/pydis_site/apps/redirect/apps.py
@@ -4,4 +4,4 @@ from django.apps import AppConfig
 class RedirectConfig(AppConfig):
     """AppConfig instance for Redirect app."""
 
-    name = 'redirect'
+    name = 'pydis_site.apps.redirect'
diff --git a/pydis_site/apps/resources/apps.py b/pydis_site/apps/resources/apps.py
index e0c235bd..93117654 100644
--- a/pydis_site/apps/resources/apps.py
+++ b/pydis_site/apps/resources/apps.py
@@ -4,4 +4,4 @@ from django.apps import AppConfig
 class ResourcesConfig(AppConfig):
     """AppConfig instance for Resources app."""
 
-    name = 'resources'
+    name = 'pydis_site.apps.resources'
diff --git a/pydis_site/apps/staff/apps.py b/pydis_site/apps/staff/apps.py
index 70a15f40..d68a80c3 100644
--- a/pydis_site/apps/staff/apps.py
+++ b/pydis_site/apps/staff/apps.py
@@ -4,4 +4,4 @@ from django.apps import AppConfig
 class StaffConfig(AppConfig):
     """Django AppConfig for the staff app."""
 
-    name = 'staff'
+    name = 'pydis_site.apps.staff'
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
index 17f220f3..03c16f4b 100644
--- a/pydis_site/settings.py
+++ b/pydis_site/settings.py
@@ -219,6 +219,9 @@ if DEBUG:
 else:
     PARENT_HOST = env('PARENT_HOST', default='pythondiscord.com')
 
+# Django Model Configuration
+DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
+
 # Django REST framework
 # https://www.django-rest-framework.org
 REST_FRAMEWORK = {
diff --git a/pyproject.toml b/pyproject.toml
index 6ef7c407..75292eb2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ license = "MIT"
 
 [tool.poetry.dependencies]
 python = "3.9.*"
-django = "~=3.1.14"
+django = "~=3.2.14"
 django-environ = "~=0.4.5"
 django-filter = "~=21.1"
 djangorestframework = "~=3.12.0"
-- 
cgit v1.2.3
From fe4def75dc0a316789cec068a574713a2b2af92f Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Tue, 12 Jul 2022 09:25:05 +0400
Subject: Add GitHub Artifact API View
Adds an API route to fetch GitHub build artifacts through a GitHub app.
Signed-off-by: Hassan Abouelela 
---
 .gitignore                                     |   3 +
 poetry.lock                                    |  67 +++++-
 pydis_site/apps/api/github_utils.py            | 183 ++++++++++++++++
 pydis_site/apps/api/tests/test_github_utils.py | 287 +++++++++++++++++++++++++
 pydis_site/apps/api/urls.py                    |   9 +-
 pydis_site/apps/api/views.py                   |  53 +++++
 pydis_site/settings.py                         |  10 +-
 pyproject.toml                                 |   1 +
 8 files changed, 609 insertions(+), 4 deletions(-)
 create mode 100644 pydis_site/apps/api/github_utils.py
 create mode 100644 pydis_site/apps/api/tests/test_github_utils.py
(limited to 'pydis_site')
diff --git a/.gitignore b/.gitignore
index 45073da5..4fc4417d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -132,3 +132,6 @@ log.*
 
 # Mac/OSX
 .DS_Store
+
+# Private keys
+*.pem
diff --git a/poetry.lock b/poetry.lock
index f6576fba..1bee4397 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -67,6 +67,17 @@ category = "main"
 optional = false
 python-versions = ">=3.6"
 
+[[package]]
+name = "cffi"
+version = "1.15.1"
+description = "Foreign Function Interface for Python calling C code."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pycparser = "*"
+
 [[package]]
 name = "cfgv"
 version = "3.3.1"
@@ -121,6 +132,25 @@ requests = ">=1.0.0"
 [package.extras]
 yaml = ["PyYAML (>=3.10)"]
 
+[[package]]
+name = "cryptography"
+version = "37.0.4"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+cffi = ">=1.12"
+
+[package.extras]
+docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
+docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
+pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
+sdist = ["setuptools_rust (>=0.11.4)"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
+
 [[package]]
 name = "distlib"
 version = "0.3.4"
@@ -607,6 +637,14 @@ category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
+[[package]]
+name = "pycparser"
+version = "2.21"
+description = "C parser in Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
 [[package]]
 name = "pydocstyle"
 version = "6.1.1"
@@ -637,6 +675,23 @@ category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
+[[package]]
+name = "pyjwt"
+version = "2.4.0"
+description = "JSON Web Token implementation in Python"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+cryptography = {version = ">=3.3.1", optional = true, markers = "extra == \"crypto\""}
+
+[package.extras]
+crypto = ["cryptography (>=3.3.1)"]
+dev = ["sphinx", "sphinx-rtd-theme", "zope.interface", "cryptography (>=3.3.1)", "pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)", "mypy", "pre-commit"]
+docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
+tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"]
+
 [[package]]
 name = "python-dotenv"
 version = "0.17.1"
@@ -876,7 +931,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-
 [metadata]
 lock-version = "1.1"
 python-versions = "3.9.*"
-content-hash = "e71d10c3d478c5d99e842f4c449a093caa1d4b2d255eb0dfb19843c5265d4aca"
+content-hash = "c656c07f40d32ee7d30c19a7084b40e1e851209a362a3fe882aa03c2fd286454"
 
 [metadata.files]
 anyio = [
@@ -896,6 +951,7 @@ certifi = [
     {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"},
     {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"},
 ]
+cffi = []
 cfgv = [
     {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"},
     {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
@@ -963,6 +1019,7 @@ coveralls = [
     {file = "coveralls-2.2.0-py2.py3-none-any.whl", hash = "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc"},
     {file = "coveralls-2.2.0.tar.gz", hash = "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"},
 ]
+cryptography = []
 distlib = [
     {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"},
     {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"},
@@ -1157,6 +1214,10 @@ pycodestyle = [
     {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
     {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
 ]
+pycparser = [
+    {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
+    {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
+]
 pydocstyle = [
     {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"},
     {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"},
@@ -1166,6 +1227,10 @@ pyflakes = [
     {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"},
     {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
 ]
+pyjwt = [
+    {file = "PyJWT-2.4.0-py3-none-any.whl", hash = "sha256:72d1d253f32dbd4f5c88eaf1fdc62f3a19f676ccbadb9dbc5d07e951b2b26daf"},
+    {file = "PyJWT-2.4.0.tar.gz", hash = "sha256:d42908208c699b3b973cbeb01a969ba6a96c821eefb1c5bfe4c390c01d67abba"},
+]
 python-dotenv = [
     {file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"},
     {file = "python_dotenv-0.17.1-py2.py3-none-any.whl", hash = "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544"},
diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py
new file mode 100644
index 00000000..70dccdff
--- /dev/null
+++ b/pydis_site/apps/api/github_utils.py
@@ -0,0 +1,183 @@
+"""Utilities for working with the GitHub API."""
+
+import asyncio
+import datetime
+import math
+
+import httpx
+import jwt
+from asgiref.sync import async_to_sync
+
+from pydis_site import settings
+
+MAX_POLLS = 20
+"""The maximum number of attempts at fetching a workflow run."""
+
+
+class ArtifactProcessingError(Exception):
+    """Base exception for other errors related to processing a GitHub artifact."""
+
+    status: int
+
+
+class UnauthorizedError(ArtifactProcessingError):
+    """The application does not have permission to access the requested repo."""
+
+    status = 401
+
+
+class NotFoundError(ArtifactProcessingError):
+    """The requested resource could not be found."""
+
+    status = 404
+
+
+class ActionFailedError(ArtifactProcessingError):
+    """The requested workflow did not conclude successfully."""
+
+    status = 400
+
+
+class RunTimeoutError(ArtifactProcessingError):
+    """The requested workflow run was not ready in time."""
+
+    status = 408
+
+
+def generate_token() -> str:
+    """
+    Generate a JWT token to access the GitHub API.
+
+    The token is valid for roughly 10 minutes after generation, before the API starts
+    returning 401s.
+
+    Refer to:
+    https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app
+    """
+    now = datetime.datetime.now()
+    return jwt.encode(
+        {
+            "iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()),  # Issued at
+            "exp": math.floor((now + datetime.timedelta(minutes=9)).timestamp()),  # Expires at
+            "iss": settings.GITHUB_OAUTH_APP_ID,
+        },
+        settings.GITHUB_OAUTH_KEY,
+        algorithm="RS256"
+    )
+
+
+async def authorize(owner: str, repo: str) -> httpx.AsyncClient:
+    """
+    Get an access token for the requested repository.
+
+    The process is roughly:
+        - GET app/installations to get a list of all app installations
+        - POST  to get a token to access the given app
+        - GET installation/repositories and check if the requested one is part of those
+    """
+    client = httpx.AsyncClient(
+        base_url=settings.GITHUB_API,
+        headers={"Authorization": f"bearer {generate_token()}"},
+        timeout=settings.TIMEOUT_PERIOD,
+    )
+
+    try:
+        # Get a list of app installations we have access to
+        apps = await client.get("app/installations")
+        apps.raise_for_status()
+
+        for app in apps.json():
+            # Look for an installation with the right owner
+            if app["account"]["login"] != owner:
+                continue
+
+            # Get the repositories of the specified owner
+            app_token = await client.post(app["access_tokens_url"])
+            app_token.raise_for_status()
+            client.headers["Authorization"] = f"bearer {app_token.json()['token']}"
+
+            repos = await client.get("installation/repositories")
+            repos.raise_for_status()
+
+            # Search for the request repository
+            for accessible_repo in repos.json()["repositories"]:
+                if accessible_repo["name"] == repo:
+                    # We've found the correct repository, and it's accessible with the current auth
+                    return client
+
+        raise NotFoundError(
+            "Could not find the requested repository. Make sure the application can access it."
+        )
+
+    except BaseException as e:
+        # Close the client if we encountered an unexpected exception
+        await client.aclose()
+        raise e
+
+
+async def wait_for_run(client: httpx.AsyncClient, run: dict) -> str:
+    """Wait for the provided `run` to finish, and return the URL to its artifacts."""
+    polls = 0
+    while polls <= MAX_POLLS:
+        if run["status"] != "completed":
+            # The action is still processing, wait a bit longer
+            polls += 1
+            await asyncio.sleep(10)
+
+        elif run["conclusion"] != "success":
+            # The action failed, or did not run
+            raise ActionFailedError(f"The requested workflow ended with: {run['conclusion']}")
+
+        else:
+            # The desired action was found, and it ended successfully
+            return run["artifacts_url"]
+
+        run = await client.get(run["url"])
+        run.raise_for_status()
+        run = run.json()
+
+    raise RunTimeoutError("The requested workflow was not ready in time.")
+
+
+@async_to_sync
+async def get_artifact(
+    owner: str, repo: str, sha: str, action_name: str, artifact_name: str
+) -> str:
+    """Get a download URL for a build artifact."""
+    client = await authorize(owner, repo)
+
+    try:
+        # Get the workflow runs for this repository
+        runs = await client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100})
+        runs.raise_for_status()
+        runs = runs.json()
+
+        # Filter the runs for the one associated with the given SHA
+        for run in runs["workflow_runs"]:
+            if run["name"] == action_name and sha == run["head_sha"]:
+                break
+        else:
+            raise NotFoundError(
+                "Could not find a run matching the provided settings in the previous hundred runs."
+            )
+
+        # Wait for the workflow to finish
+        url = await wait_for_run(client, run)
+
+        # Filter the artifacts, and return the download URL
+        artifacts = await client.get(url)
+        artifacts.raise_for_status()
+
+        for artifact in artifacts.json()["artifacts"]:
+            if artifact["name"] == artifact_name:
+                data = await client.get(artifact["archive_download_url"])
+                if data.status_code == 302:
+                    return str(data.next_request.url)
+
+                # The following line is left untested since it should in theory be impossible
+                data.raise_for_status()  # pragma: no cover
+
+        raise NotFoundError("Could not find an artifact matching the provided name.")
+
+    finally:
+        await client.aclose()
diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py
new file mode 100644
index 00000000..dc17d609
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_github_utils.py
@@ -0,0 +1,287 @@
+import asyncio
+import datetime
+import random
+import unittest
+from unittest import mock
+
+import django.test
+import httpx
+import jwt
+import rest_framework.response
+import rest_framework.test
+from django.urls import reverse
+
+from .. import github_utils
+
+
+def patched_raise_for_status(response: httpx.Response):
+    """Fake implementation of raise_for_status which does not need a request to be set."""
+    if response.status_code // 100 != 2:  # pragma: no cover
+        raise httpx.HTTPStatusError(
+            f"Non 2xx response code: {response.status_code}",
+            request=getattr(response, "_request", httpx.Request("GET", "")),
+            response=response
+        )
+
+
+class GeneralUtilityTests(unittest.TestCase):
+    """Test the utility methods which do not fit in another class."""
+
+    def test_token_generation(self):
+        """Test that the a valid JWT token is generated."""
+        def encode(payload: dict, _: str, algorithm: str, *args, **kwargs) -> str:
+            """
+            Intercept the encode method.
+
+            It is performed with an algorithm which does not require a PEM key, as it may
+            not be available in testing environments.
+            """
+            self.assertEqual("RS256", algorithm, "The GitHub App JWT must be signed using RS256.")
+            return original_encode(
+                payload, "secret-encoding-key", algorithm="HS256", *args, **kwargs
+            )
+
+        original_encode = jwt.encode
+        with mock.patch("jwt.encode", new=encode):
+            token = github_utils.generate_token()
+        decoded = jwt.decode(token, "secret-encoding-key", algorithms=["HS256"])
+
+        delta = datetime.timedelta(minutes=10)
+        self.assertAlmostEqual(decoded["exp"] - decoded["iat"], delta.total_seconds())
+        self.assertLess(decoded["exp"], (datetime.datetime.now() + delta).timestamp())
+
+
+@mock.patch("httpx.AsyncClient", autospec=True)
+@mock.patch("asyncio.sleep", new=mock.AsyncMock(return_value=asyncio.Future))
+@mock.patch("httpx.Response.raise_for_status", new=patched_raise_for_status)
+class WaitForTests(unittest.IsolatedAsyncioTestCase):
+    """Tests the wait_for utility."""
+
+    async def test_wait_for_successful_run(self, client_mock: mock.Mock):
+        """Test that the wait_for method handles successfully runs."""
+        final_url = "some_url" + str(random.randint(0, 10))
+
+        client_mock.get.side_effect = responses = [
+            httpx.Response(200, json={"status": "queued", "url": ""}),
+            httpx.Response(200, json={"status": "pending", "url": ""}),
+            httpx.Response(200, json={
+                "status": "completed",
+                "conclusion": "success",
+                "url": "",
+                "artifacts_url": final_url
+            })
+        ]
+
+        result = await github_utils.wait_for_run(client_mock, responses[0].json())
+        self.assertEqual(final_url, result)
+
+    async def test_wait_for_failed_run(self, client_mock: mock.Mock):
+        """Test that the wait_for method handles failed runs."""
+        client_mock.get.return_value = httpx.Response(200, json={
+            "status": "completed",
+            "conclusion": "failed",
+        })
+
+        with self.assertRaises(github_utils.ActionFailedError):
+            await github_utils.wait_for_run(client_mock, {"status": "pending", "url": ""})
+
+    async def test_wait_for_timeout(self, client_mock: mock.Mock):
+        """Test that the wait_for method quits after a few attempts."""
+        client_mock.get.side_effect = responses = [
+            httpx.Response(200, json={"status": "pending", "url": ""})
+        ] * (github_utils.MAX_POLLS + 5)
+
+        with self.assertRaises(github_utils.RunTimeoutError):
+            await github_utils.wait_for_run(client_mock, responses[0].json())
+
+
+async def get_response_authorize(
+    _: httpx.AsyncClient, request: httpx.Request, **__
+) -> httpx.Response:
+    """
+    Helper method for the authorize tests.
+
+    Requests are intercepted before being sent out, and the appropriate responses are returned.
+    """
+    path = request.url.path
+    auth = request.headers.get("Authorization")
+
+    if request.method == "GET":
+        if path == "/app/installations":
+            if auth == "bearer JWT initial token":
+                return httpx.Response(200, request=request, json=[{
+                    "account": {"login": "VALID_OWNER"},
+                    "access_tokens_url": "https://example.com/ACCESS_TOKEN_URL"
+                }])
+            else:
+                return httpx.Response(
+                    401, json={"error": "auth app/installations"}, request=request
+                )
+
+        elif path == "/installation/repositories":
+            if auth == "bearer app access token":
+                return httpx.Response(200, request=request, json={
+                    "repositories": [{
+                        "name": "VALID_REPO"
+                    }]
+                })
+            else:  # pragma: no cover
+                return httpx.Response(
+                    401, json={"error": "auth installation/repositories"}, request=request
+                )
+
+    elif request.method == "POST":
+        if path == "/ACCESS_TOKEN_URL":
+            if auth == "bearer JWT initial token":
+                return httpx.Response(200, request=request, json={"token": "app access token"})
+            else:  # pragma: no cover
+                return httpx.Response(401, json={"error": "auth access_token"}, request=request)
+
+    # Reaching this point means something has gone wrong
+    return httpx.Response(500, request=request)  # pragma: no cover
+
+
+@mock.patch("httpx.AsyncClient.send", new=get_response_authorize)
+@mock.patch.object(github_utils, "generate_token", new=mock.Mock(return_value="JWT initial token"))
+class AuthorizeTests(unittest.IsolatedAsyncioTestCase):
+    """Test the authorize utility."""
+
+    async def test_invalid_apps_auth(self):
+        """Test that an exception is raised if authorization was attempted with an invalid token."""
+        with mock.patch.object(github_utils, "generate_token", return_value="Invalid token"):
+            with self.assertRaises(httpx.HTTPStatusError) as error:
+                await github_utils.authorize("VALID_OWNER", "VALID_REPO")
+
+        exception: httpx.HTTPStatusError = error.exception
+        self.assertEqual(401, exception.response.status_code)
+        self.assertEqual("auth app/installations", exception.response.json()["error"])
+
+    async def test_missing_repo(self):
+        """Test that an exception is raised when the selected owner or repo are not available."""
+        with self.assertRaises(github_utils.NotFoundError):
+            await github_utils.authorize("INVALID_OWNER", "VALID_REPO")
+        with self.assertRaises(github_utils.NotFoundError):
+            await github_utils.authorize("VALID_OWNER", "INVALID_REPO")
+
+    async def test_valid_authorization(self):
+        """Test that an accessible repository can be accessed."""
+        client = await github_utils.authorize("VALID_OWNER", "VALID_REPO")
+        self.assertEqual("bearer app access token", client.headers.get("Authorization"))
+
+
+async def get_response_get_artifact(request: httpx.Request, **_) -> httpx.Response:
+    """
+    Helper method for the get_artifact tests.
+
+    Requests are intercepted before being sent out, and the appropriate responses are returned.
+    """
+    path = request.url.path
+
+    if "force_error" in path:
+        return httpx.Response(404, request=request)
+
+    if request.method == "GET":
+        if path == "/repos/owner/repo/actions/runs":
+            return httpx.Response(200, request=request, json={"workflow_runs": [{
+                "name": "action_name",
+                "head_sha": "action_sha"
+            }]})
+        elif path == "/artifact_url":
+            return httpx.Response(200, request=request, json={"artifacts": [{
+                "name": "artifact_name",
+                "archive_download_url": "artifact_download_url"
+            }]})
+        elif path == "/artifact_download_url":
+            response = httpx.Response(302, request=request)
+            response.next_request = httpx.Request("GET", httpx.URL("https://final_download.url"))
+            return response
+
+    # Reaching this point means something has gone wrong
+    return httpx.Response(500, request=request)  # pragma: no cover
+
+
+class ArtifactFetcherTests(unittest.IsolatedAsyncioTestCase):
+    """Test the get_artifact utility."""
+
+    def setUp(self) -> None:
+        self.call_args = ["owner", "repo", "action_sha", "action_name", "artifact_name"]
+        self.client = httpx.AsyncClient(base_url="https://example.com")
+
+        self.patchers = [
+            mock.patch.object(self.client, "send", new=get_response_get_artifact),
+            mock.patch.object(github_utils, "authorize", return_value=self.client),
+            mock.patch.object(github_utils, "wait_for_run", return_value="artifact_url"),
+        ]
+
+        for patcher in self.patchers:
+            patcher.start()
+
+    def tearDown(self) -> None:
+        for patcher in self.patchers:
+            patcher.stop()
+
+    def test_client_closed_on_errors(self):
+        """Test that the client is terminated even if an error occurs at some point."""
+        self.call_args[0] = "force_error"
+        with self.assertRaises(httpx.HTTPStatusError):
+            github_utils.get_artifact(*self.call_args)
+        self.assertTrue(self.client.is_closed)
+
+    def test_missing(self):
+        """Test that an exception is raised if the requested artifact was not found."""
+        cases = (
+            "invalid sha",
+            "invalid action name",
+            "invalid artifact name",
+        )
+        for i, name in enumerate(cases, 2):
+            with self.subTest(f"Test {name} raises an error"):
+                new_args = self.call_args.copy()
+                new_args[i] = name
+
+                with self.assertRaises(github_utils.NotFoundError):
+                    github_utils.get_artifact(*new_args)
+
+    def test_valid(self):
+        """Test that the correct download URL is returned for valid requests."""
+        url = github_utils.get_artifact(*self.call_args)
+        self.assertEqual("https://final_download.url", url)
+        self.assertTrue(self.client.is_closed)
+
+
+@mock.patch.object(github_utils, "get_artifact")
+class GitHubArtifactViewTests(django.test.TestCase):
+    """Test the GitHub artifact fetch API view."""
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+
+        cls.kwargs = {
+            "owner": "test_owner",
+            "repo": "test_repo",
+            "sha": "test_sha",
+            "action_name": "test_action",
+            "artifact_name": "test_artifact",
+        }
+        cls.url = reverse("api:github-artifacts", kwargs=cls.kwargs)
+
+    async def test_successful(self, artifact_mock: mock.Mock):
+        """Test a proper response is returned with proper input."""
+        artifact_mock.return_value = "final download url"
+        result = self.client.get(self.url)
+
+        self.assertIsInstance(result, rest_framework.response.Response)
+        self.assertEqual({"url": artifact_mock.return_value}, result.data)
+
+    async def test_failed_fetch(self, artifact_mock: mock.Mock):
+        """Test that a proper error is returned when the request fails."""
+        artifact_mock.side_effect = github_utils.NotFoundError("Test error message")
+        result = self.client.get(self.url)
+
+        self.assertIsInstance(result, rest_framework.response.Response)
+        self.assertEqual({
+            "error_type": github_utils.NotFoundError.__name__,
+            "error": "Test error message",
+            "requested_resource": "/".join(self.kwargs.values())
+        }, result.data)
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index 1e564b29..2757f176 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -1,7 +1,7 @@
 from django.urls import include, path
 from rest_framework.routers import DefaultRouter
 
-from .views import HealthcheckView, RulesView
+from .views import GitHubArtifactsView, HealthcheckView, RulesView
 from .viewsets import (
     AocAccountLinkViewSet,
     AocCompletionistBlockViewSet,
@@ -86,5 +86,10 @@ urlpatterns = (
     # from django_hosts.resolvers import reverse
     path('bot/', include((bot_router.urls, 'api'), namespace='bot')),
     path('healthcheck', HealthcheckView.as_view(), name='healthcheck'),
-    path('rules', RulesView.as_view(), name='rules')
+    path('rules', RulesView.as_view(), name='rules'),
+    path(
+        'github/artifact/////',
+        GitHubArtifactsView.as_view(),
+        name="github-artifacts"
+    ),
 )
diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py
index 816463f6..ad2d948e 100644
--- a/pydis_site/apps/api/views.py
+++ b/pydis_site/apps/api/views.py
@@ -1,7 +1,10 @@
 from rest_framework.exceptions import ParseError
+from rest_framework.request import Request
 from rest_framework.response import Response
 from rest_framework.views import APIView
 
+from . import github_utils
+
 
 class HealthcheckView(APIView):
     """
@@ -152,3 +155,53 @@ class RulesView(APIView):
                 "Do not offer or ask for paid work of any kind."
             ),
         ])
+
+
+class GitHubArtifactsView(APIView):
+    """
+    Provides utilities for interacting with the GitHub API and obtaining action artifacts.
+
+    ## Routes
+    ### GET /github/artifacts
+    Returns a download URL for the artifact requested.
+
+        {
+            'url': 'https://pipelines.actions.githubusercontent.com/...'
+        }
+
+    ### Exceptions
+    In case of an error, the following body will be returned:
+
+        {
+            "error_type": "",
+            "error": "",
+            "requested_resource": "///"
+        }
+
+    ## Authentication
+    Does not require any authentication nor permissions.
+    """
+
+    authentication_classes = ()
+    permission_classes = ()
+
+    def get(
+        self,
+        request: Request,
+        *,
+        owner: str,
+        repo: str,
+        sha: str,
+        action_name: str,
+        artifact_name: str
+    ) -> Response:
+        """Return a download URL for the requested artifact."""
+        try:
+            url = github_utils.get_artifact(owner, repo, sha, action_name, artifact_name)
+            return Response({"url": url})
+        except github_utils.ArtifactProcessingError as e:
+            return Response({
+                "error_type": e.__class__.__name__,
+                "error": str(e),
+                "requested_resource": f"{owner}/{repo}/{sha}/{action_name}/{artifact_name}"
+            }, status=e.status)
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
index 03c16f4b..f382b052 100644
--- a/pydis_site/settings.py
+++ b/pydis_site/settings.py
@@ -21,7 +21,6 @@ import environ
 import sentry_sdk
 from sentry_sdk.integrations.django import DjangoIntegration
 
-
 env = environ.Env(
     DEBUG=(bool, False),
     SITE_DSN=(str, ""),
@@ -30,10 +29,19 @@ env = environ.Env(
     GIT_SHA=(str, 'development'),
     TIMEOUT_PERIOD=(int, 5),
     GITHUB_TOKEN=(str, None),
+    GITHUB_OAUTH_APP_ID=(str, None),
+    GITHUB_OAUTH_KEY=(str, None),
 )
 
 GIT_SHA = env("GIT_SHA")
+GITHUB_API = "https://api.github.com"
 GITHUB_TOKEN = env("GITHUB_TOKEN")
+GITHUB_OAUTH_APP_ID = env("GITHUB_OAUTH_APP_ID")
+GITHUB_OAUTH_KEY = env("GITHUB_OAUTH_KEY")
+
+if GITHUB_OAUTH_KEY and (oauth_file := Path(GITHUB_OAUTH_KEY)).is_file():
+    # Allow the OAuth key to be loaded from a file
+    GITHUB_OAUTH_KEY = oauth_file.read_text(encoding="utf-8")
 
 sentry_sdk.init(
     dsn=env('SITE_DSN'),
diff --git a/pyproject.toml b/pyproject.toml
index 467fc8bc..1c24d308 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,7 @@ markdown = "~=3.3.4"
 python-frontmatter = "~=1.0"
 django-prometheus = "~=2.1"
 django-distill = "~=2.9.0"
+PyJWT = {version = "~=2.4.0", extras = ["crypto"]}
 
 [tool.poetry.dev-dependencies]
 coverage = "~=5.0"
-- 
cgit v1.2.3
From 26a3c19b53883015e8ba87db2a668c3eece2ce20 Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Tue, 12 Jul 2022 14:45:22 +0400
Subject: Make Awaiting Workflow Run A User Responsibility
Moves the responsibility of re-requesting a workflow run from the API to
the user. This makes the requests much shorter-lived, and allows the
client to control how they want to handle sleeping and retrying.
This also has the benefit of removing the only real piece of async code,
so now the view is completely sync once again.
Signed-off-by: Hassan Abouelela 
---
 pydis_site/apps/api/github_utils.py            |  79 +++++------
 pydis_site/apps/api/tests/test_github_utils.py | 174 ++++++++++++-------------
 static-builds/netlify_build.py                 |   9 +-
 3 files changed, 131 insertions(+), 131 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py
index 70dccdff..707b36e5 100644
--- a/pydis_site/apps/api/github_utils.py
+++ b/pydis_site/apps/api/github_utils.py
@@ -1,17 +1,17 @@
 """Utilities for working with the GitHub API."""
 
-import asyncio
 import datetime
 import math
 
 import httpx
 import jwt
-from asgiref.sync import async_to_sync
 
 from pydis_site import settings
 
-MAX_POLLS = 20
-"""The maximum number of attempts at fetching a workflow run."""
+MAX_RUN_TIME = datetime.timedelta(minutes=3)
+"""The maximum time allowed before an action is declared timed out."""
+ISO_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ"
+"""The datetime string format GitHub uses."""
 
 
 class ArtifactProcessingError(Exception):
@@ -44,6 +44,12 @@ class RunTimeoutError(ArtifactProcessingError):
     status = 408
 
 
+class RunPendingError(ArtifactProcessingError):
+    """The requested workflow run is still pending, try again later."""
+
+    status = 202
+
+
 def generate_token() -> str:
     """
     Generate a JWT token to access the GitHub API.
@@ -66,7 +72,7 @@ def generate_token() -> str:
     )
 
 
-async def authorize(owner: str, repo: str) -> httpx.AsyncClient:
+def authorize(owner: str, repo: str) -> httpx.Client:
     """
     Get an access token for the requested repository.
 
@@ -75,7 +81,7 @@ async def authorize(owner: str, repo: str) -> httpx.AsyncClient:
         - POST  to get a token to access the given app
         - GET installation/repositories and check if the requested one is part of those
     """
-    client = httpx.AsyncClient(
+    client = httpx.Client(
         base_url=settings.GITHUB_API,
         headers={"Authorization": f"bearer {generate_token()}"},
         timeout=settings.TIMEOUT_PERIOD,
@@ -83,7 +89,7 @@ async def authorize(owner: str, repo: str) -> httpx.AsyncClient:
 
     try:
         # Get a list of app installations we have access to
-        apps = await client.get("app/installations")
+        apps = client.get("app/installations")
         apps.raise_for_status()
 
         for app in apps.json():
@@ -92,11 +98,11 @@ async def authorize(owner: str, repo: str) -> httpx.AsyncClient:
                 continue
 
             # Get the repositories of the specified owner
-            app_token = await client.post(app["access_tokens_url"])
+            app_token = client.post(app["access_tokens_url"])
             app_token.raise_for_status()
             client.headers["Authorization"] = f"bearer {app_token.json()['token']}"
 
-            repos = await client.get("installation/repositories")
+            repos = client.get("installation/repositories")
             repos.raise_for_status()
 
             # Search for the request repository
@@ -111,44 +117,39 @@ async def authorize(owner: str, repo: str) -> httpx.AsyncClient:
 
     except BaseException as e:
         # Close the client if we encountered an unexpected exception
-        await client.aclose()
+        client.close()
         raise e
 
 
-async def wait_for_run(client: httpx.AsyncClient, run: dict) -> str:
-    """Wait for the provided `run` to finish, and return the URL to its artifacts."""
-    polls = 0
-    while polls <= MAX_POLLS:
-        if run["status"] != "completed":
-            # The action is still processing, wait a bit longer
-            polls += 1
-            await asyncio.sleep(10)
-
-        elif run["conclusion"] != "success":
-            # The action failed, or did not run
-            raise ActionFailedError(f"The requested workflow ended with: {run['conclusion']}")
+def check_run_status(run: dict) -> str:
+    """Check if the provided run has been completed, otherwise raise an exception."""
+    created_at = datetime.datetime.strptime(run["created_at"], ISO_FORMAT_STRING)
+    run_time = datetime.datetime.now() - created_at
 
+    if run["status"] != "completed":
+        if run_time <= MAX_RUN_TIME:
+            raise RunPendingError(
+                f"The requested run is still pending. It was created "
+                f"{run_time.seconds // 60}:{run_time.seconds % 60 :>02} minutes ago."
+            )
         else:
-            # The desired action was found, and it ended successfully
-            return run["artifacts_url"]
+            raise RunTimeoutError("The requested workflow was not ready in time.")
 
-        run = await client.get(run["url"])
-        run.raise_for_status()
-        run = run.json()
+    if run["conclusion"] != "success":
+        # The action failed, or did not run
+        raise ActionFailedError(f"The requested workflow ended with: {run['conclusion']}")
 
-    raise RunTimeoutError("The requested workflow was not ready in time.")
+    # The requested action is ready
+    return run["artifacts_url"]
 
 
-@async_to_sync
-async def get_artifact(
-    owner: str, repo: str, sha: str, action_name: str, artifact_name: str
-) -> str:
+def get_artifact(owner: str, repo: str, sha: str, action_name: str, artifact_name: str) -> str:
     """Get a download URL for a build artifact."""
-    client = await authorize(owner, repo)
+    client = authorize(owner, repo)
 
     try:
         # Get the workflow runs for this repository
-        runs = await client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100})
+        runs = client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100})
         runs.raise_for_status()
         runs = runs.json()
 
@@ -161,16 +162,16 @@ async def get_artifact(
                 "Could not find a run matching the provided settings in the previous hundred runs."
             )
 
-        # Wait for the workflow to finish
-        url = await wait_for_run(client, run)
+        # Check the workflow status
+        url = check_run_status(run)
 
         # Filter the artifacts, and return the download URL
-        artifacts = await client.get(url)
+        artifacts = client.get(url)
         artifacts.raise_for_status()
 
         for artifact in artifacts.json()["artifacts"]:
             if artifact["name"] == artifact_name:
-                data = await client.get(artifact["archive_download_url"])
+                data = client.get(artifact["archive_download_url"])
                 if data.status_code == 302:
                     return str(data.next_request.url)
 
@@ -180,4 +181,4 @@ async def get_artifact(
         raise NotFoundError("Could not find an artifact matching the provided name.")
 
     finally:
-        await client.aclose()
+        client.close()
diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py
index dc17d609..78f2979d 100644
--- a/pydis_site/apps/api/tests/test_github_utils.py
+++ b/pydis_site/apps/api/tests/test_github_utils.py
@@ -1,6 +1,4 @@
-import asyncio
 import datetime
-import random
 import unittest
 from unittest import mock
 
@@ -14,16 +12,6 @@ from django.urls import reverse
 from .. import github_utils
 
 
-def patched_raise_for_status(response: httpx.Response):
-    """Fake implementation of raise_for_status which does not need a request to be set."""
-    if response.status_code // 100 != 2:  # pragma: no cover
-        raise httpx.HTTPStatusError(
-            f"Non 2xx response code: {response.status_code}",
-            request=getattr(response, "_request", httpx.Request("GET", "")),
-            response=response
-        )
-
-
 class GeneralUtilityTests(unittest.TestCase):
     """Test the utility methods which do not fit in another class."""
 
@@ -51,53 +39,50 @@ class GeneralUtilityTests(unittest.TestCase):
         self.assertLess(decoded["exp"], (datetime.datetime.now() + delta).timestamp())
 
 
-@mock.patch("httpx.AsyncClient", autospec=True)
-@mock.patch("asyncio.sleep", new=mock.AsyncMock(return_value=asyncio.Future))
-@mock.patch("httpx.Response.raise_for_status", new=patched_raise_for_status)
-class WaitForTests(unittest.IsolatedAsyncioTestCase):
-    """Tests the wait_for utility."""
-
-    async def test_wait_for_successful_run(self, client_mock: mock.Mock):
-        """Test that the wait_for method handles successfully runs."""
-        final_url = "some_url" + str(random.randint(0, 10))
-
-        client_mock.get.side_effect = responses = [
-            httpx.Response(200, json={"status": "queued", "url": ""}),
-            httpx.Response(200, json={"status": "pending", "url": ""}),
-            httpx.Response(200, json={
-                "status": "completed",
-                "conclusion": "success",
-                "url": "",
-                "artifacts_url": final_url
-            })
-        ]
+class WaitForTests(unittest.TestCase):
+    """Tests the check_run_status utility."""
 
-        result = await github_utils.wait_for_run(client_mock, responses[0].json())
-        self.assertEqual(final_url, result)
+    def test_completed_run(self):
+        final_url = "some_url_string_1234"
 
-    async def test_wait_for_failed_run(self, client_mock: mock.Mock):
-        """Test that the wait_for method handles failed runs."""
-        client_mock.get.return_value = httpx.Response(200, json={
+        result = github_utils.check_run_status({
             "status": "completed",
-            "conclusion": "failed",
+            "conclusion": "success",
+            "created_at": datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING),
+            "artifacts_url": final_url,
         })
+        self.assertEqual(final_url, result)
 
-        with self.assertRaises(github_utils.ActionFailedError):
-            await github_utils.wait_for_run(client_mock, {"status": "pending", "url": ""})
+    def test_pending_run(self):
+        """Test that a pending run raises the proper exception."""
+        with self.assertRaises(github_utils.RunPendingError):
+            github_utils.check_run_status({
+                "status": "pending",
+                "created_at": datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING),
+            })
 
-    async def test_wait_for_timeout(self, client_mock: mock.Mock):
-        """Test that the wait_for method quits after a few attempts."""
-        client_mock.get.side_effect = responses = [
-            httpx.Response(200, json={"status": "pending", "url": ""})
-        ] * (github_utils.MAX_POLLS + 5)
+    def test_timeout_error(self):
+        """Test that a timeout is declared after a certain duration."""
+        # Set the creation time to well before the MAX_RUN_TIME
+        # to guarantee the right conclusion
+        created = (
+            datetime.datetime.now() - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10)
+        ).strftime(github_utils.ISO_FORMAT_STRING)
 
         with self.assertRaises(github_utils.RunTimeoutError):
-            await github_utils.wait_for_run(client_mock, responses[0].json())
+            github_utils.check_run_status({"status": "pending", "created_at": created})
+
+    def test_failed_run(self):
+        """Test that a failed run raises the proper exception."""
+        with self.assertRaises(github_utils.ActionFailedError):
+            github_utils.check_run_status({
+                "status": "completed",
+                "conclusion": "failed",
+                "created_at": datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING),
+            })
 
 
-async def get_response_authorize(
-    _: httpx.AsyncClient, request: httpx.Request, **__
-) -> httpx.Response:
+def get_response_authorize(_: httpx.Client, request: httpx.Request, **__) -> httpx.Response:
     """
     Helper method for the authorize tests.
 
@@ -141,76 +126,83 @@ async def get_response_authorize(
     return httpx.Response(500, request=request)  # pragma: no cover
 
 
-@mock.patch("httpx.AsyncClient.send", new=get_response_authorize)
+@mock.patch("httpx.Client.send", new=get_response_authorize)
 @mock.patch.object(github_utils, "generate_token", new=mock.Mock(return_value="JWT initial token"))
-class AuthorizeTests(unittest.IsolatedAsyncioTestCase):
+class AuthorizeTests(unittest.TestCase):
     """Test the authorize utility."""
 
-    async def test_invalid_apps_auth(self):
+    def test_invalid_apps_auth(self):
         """Test that an exception is raised if authorization was attempted with an invalid token."""
         with mock.patch.object(github_utils, "generate_token", return_value="Invalid token"):
             with self.assertRaises(httpx.HTTPStatusError) as error:
-                await github_utils.authorize("VALID_OWNER", "VALID_REPO")
+                github_utils.authorize("VALID_OWNER", "VALID_REPO")
 
         exception: httpx.HTTPStatusError = error.exception
         self.assertEqual(401, exception.response.status_code)
         self.assertEqual("auth app/installations", exception.response.json()["error"])
 
-    async def test_missing_repo(self):
+    def test_missing_repo(self):
         """Test that an exception is raised when the selected owner or repo are not available."""
         with self.assertRaises(github_utils.NotFoundError):
-            await github_utils.authorize("INVALID_OWNER", "VALID_REPO")
+            github_utils.authorize("INVALID_OWNER", "VALID_REPO")
         with self.assertRaises(github_utils.NotFoundError):
-            await github_utils.authorize("VALID_OWNER", "INVALID_REPO")
+            github_utils.authorize("VALID_OWNER", "INVALID_REPO")
 
-    async def test_valid_authorization(self):
+    def test_valid_authorization(self):
         """Test that an accessible repository can be accessed."""
-        client = await github_utils.authorize("VALID_OWNER", "VALID_REPO")
+        client = github_utils.authorize("VALID_OWNER", "VALID_REPO")
         self.assertEqual("bearer app access token", client.headers.get("Authorization"))
 
 
-async def get_response_get_artifact(request: httpx.Request, **_) -> httpx.Response:
-    """
-    Helper method for the get_artifact tests.
+class ArtifactFetcherTests(unittest.TestCase):
+    """Test the get_artifact utility."""
 
-    Requests are intercepted before being sent out, and the appropriate responses are returned.
-    """
-    path = request.url.path
+    @staticmethod
+    def get_response_get_artifact(request: httpx.Request, **_) -> httpx.Response:
+        """
+        Helper method for the get_artifact tests.
 
-    if "force_error" in path:
-        return httpx.Response(404, request=request)
+        Requests are intercepted before being sent out, and the appropriate responses are returned.
+        """
+        path = request.url.path
 
-    if request.method == "GET":
-        if path == "/repos/owner/repo/actions/runs":
-            return httpx.Response(200, request=request, json={"workflow_runs": [{
-                "name": "action_name",
-                "head_sha": "action_sha"
-            }]})
-        elif path == "/artifact_url":
-            return httpx.Response(200, request=request, json={"artifacts": [{
-                "name": "artifact_name",
-                "archive_download_url": "artifact_download_url"
-            }]})
-        elif path == "/artifact_download_url":
-            response = httpx.Response(302, request=request)
-            response.next_request = httpx.Request("GET", httpx.URL("https://final_download.url"))
-            return response
-
-    # Reaching this point means something has gone wrong
-    return httpx.Response(500, request=request)  # pragma: no cover
+        if "force_error" in path:
+            return httpx.Response(404, request=request)
 
+        if request.method == "GET":
+            if path == "/repos/owner/repo/actions/runs":
+                return httpx.Response(
+                    200, request=request, json={"workflow_runs": [{
+                        "name": "action_name",
+                        "head_sha": "action_sha"
+                    }]}
+                )
+            elif path == "/artifact_url":
+                return httpx.Response(
+                    200, request=request, json={"artifacts": [{
+                        "name": "artifact_name",
+                        "archive_download_url": "artifact_download_url"
+                    }]}
+                )
+            elif path == "/artifact_download_url":
+                response = httpx.Response(302, request=request)
+                response.next_request = httpx.Request(
+                    "GET",
+                    httpx.URL("https://final_download.url")
+                )
+                return response
 
-class ArtifactFetcherTests(unittest.IsolatedAsyncioTestCase):
-    """Test the get_artifact utility."""
+        # Reaching this point means something has gone wrong
+        return httpx.Response(500, request=request)  # pragma: no cover
 
     def setUp(self) -> None:
         self.call_args = ["owner", "repo", "action_sha", "action_name", "artifact_name"]
-        self.client = httpx.AsyncClient(base_url="https://example.com")
+        self.client = httpx.Client(base_url="https://example.com")
 
         self.patchers = [
-            mock.patch.object(self.client, "send", new=get_response_get_artifact),
+            mock.patch.object(self.client, "send", new=self.get_response_get_artifact),
             mock.patch.object(github_utils, "authorize", return_value=self.client),
-            mock.patch.object(github_utils, "wait_for_run", return_value="artifact_url"),
+            mock.patch.object(github_utils, "check_run_status", return_value="artifact_url"),
         ]
 
         for patcher in self.patchers:
@@ -266,7 +258,7 @@ class GitHubArtifactViewTests(django.test.TestCase):
         }
         cls.url = reverse("api:github-artifacts", kwargs=cls.kwargs)
 
-    async def test_successful(self, artifact_mock: mock.Mock):
+    def test_successful(self, artifact_mock: mock.Mock):
         """Test a proper response is returned with proper input."""
         artifact_mock.return_value = "final download url"
         result = self.client.get(self.url)
@@ -274,7 +266,7 @@ class GitHubArtifactViewTests(django.test.TestCase):
         self.assertIsInstance(result, rest_framework.response.Response)
         self.assertEqual({"url": artifact_mock.return_value}, result.data)
 
-    async def test_failed_fetch(self, artifact_mock: mock.Mock):
+    def test_failed_fetch(self, artifact_mock: mock.Mock):
         """Test that a proper error is returned when the request fails."""
         artifact_mock.side_effect = github_utils.NotFoundError("Test error message")
         result = self.client.get(self.url)
diff --git a/static-builds/netlify_build.py b/static-builds/netlify_build.py
index 13cd0279..a473bd91 100644
--- a/static-builds/netlify_build.py
+++ b/static-builds/netlify_build.py
@@ -8,6 +8,7 @@
 
 import json
 import os
+import time
 import zipfile
 from pathlib import Path
 from urllib import parse
@@ -29,7 +30,7 @@ if __name__ == "__main__":
     print(f"Fetching download URL from {download_url}")
     response = httpx.get(download_url, follow_redirects=True)
 
-    if response.status_code != 200:
+    if response.status_code // 100 != 2:
         try:
             print(response.json())
         except json.JSONDecodeError:
@@ -37,6 +38,12 @@ if __name__ == "__main__":
 
         response.raise_for_status()
 
+    # The workflow is still pending, retry in a bit
+    while response.status_code == 202:
+        print(f"{response.json()['error']}. Retrying in 10 seconds.")
+        time.sleep(10)
+        response = httpx.get(download_url, follow_redirects=True)
+
     url = response.json()["url"]
     print(f"Downloading build from {url}")
     zipped_content = httpx.get(url, follow_redirects=True)
-- 
cgit v1.2.3
From f6d7afceacf4a30630037777dcf7a4c719923e61 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 14 Jul 2022 01:16:47 +0200
Subject: Remove `flask-socketio` from approved frameworks
---
 .../templates/events/pages/code-jams/9/frameworks.html     | 14 --------------
 1 file changed, 14 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 355bf9c3..5bb48bc4 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -82,20 +82,6 @@
          
      
 
-    
-        
-            
-                
Flask-SocketIO
-                
Flask-SocketIO gives Flask applications access to low latency bi-directional communications between the clients and the server.
-                
-            
-        
-        
-    
-
     
         
             
-- 
cgit v1.2.3
From bb29def3418df6f6c9b2d0ad9c428fcac05d0d15 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 14 Jul 2022 01:54:40 +0200
Subject: Move Starlite and Sanic above wsproto
---
 .../events/pages/code-jams/9/frameworks.html       | 24 +++++++++++-----------
 1 file changed, 12 insertions(+), 12 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 5bb48bc4..6efe511a 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -85,42 +85,42 @@
     
         
             
-                
wsproto
-                
wsproto is a pure-Python WebSocket protocol stack written to be as flexible as possible by having the user build the bridge to the I/O.
+                
Starlite
+                
Starlite is a light and flexible ASGI API framework, using Starlette and Pydantic as foundations.
                 
              
          
         
      
 
     
         
             
-                
Starlite
-                
Starlite is a light and flexible ASGI API framework, using Starlette and Pydantic as foundations.
+                
Sanic
+                
Sanic is an ASGI compliant web framework designed for speed and simplicity.
                 
              
          
         
      
 
     
         
             
-                
Sanic
-                
Sanic is an ASGI compliant web framework designed for speed and simplicity.
+                
wsproto
+                
wsproto is a pure-Python WebSocket protocol stack written to be as flexible as possible by having the user build the bridge to the I/O.
                 
              
          
         
      
 
-- 
cgit v1.2.3
From 8d20f8403d1067fc208a520b585360e9b298d13e Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 14 Jul 2022 01:57:40 +0200
Subject: Add `aiohttp` to approved frameworks
---
 .../templates/events/pages/code-jams/9/frameworks.html     | 14 ++++++++++++++
 1 file changed, 14 insertions(+)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 6efe511a..57071463 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -68,6 +68,20 @@
          
      
 
+    
+        
+            
+                
AioHTTP
+                
AioHTTP provides both a client and server WebSocket implementation, while avoiding callback-hell.
+                
+            
+        
+        
+    
+
     
         
             
-- 
cgit v1.2.3
From 2d8520a0f3211e3d788d431287359b496782c962 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 14 Jul 2022 02:46:03 +0200
Subject: Add rule about forced usage of WebSockets
---
 pydis_site/templates/events/pages/code-jams/9/rules.html | 9 +++++++++
 1 file changed, 9 insertions(+)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/rules.html b/pydis_site/templates/events/pages/code-jams/9/rules.html
index 72c0372e..a88c795a 100644
--- a/pydis_site/templates/events/pages/code-jams/9/rules.html
+++ b/pydis_site/templates/events/pages/code-jams/9/rules.html
@@ -12,6 +12,15 @@
 {% block event_content %}
 
     Your solution must use one of the approved frameworks (a list will be released soon). It is not permitted to circumvent this rule by e.g. using the approved framework as a wrapper for another framework.
 
+    - 
+        
Your solution must only communicate through WebSockets. Any communication between the client and server (or any two server workers), must utilize WebSockets.
+
+        
+            Exceptions to this rule are made for resources such as databases and files, albeit excluding usage other than for storage.
+            For example, you may use PostgreSQL as a database but not its `NOTIFY` command.
+            Lastly, working with subprocesses (through stdin/stdout or multiprocessing.Pool()/concurrent.futures.ProcessPoolExecutor()() is also exempted from this rule.
+        
+     
     Your solution should be platform agnostic. For example, if you use filepaths in your submission, use pathlib to create platform agnostic Path objects instead of hardcoding the paths.
 
     - 
         
-- 
cgit v1.2.3
From 2d353e93715205d3365730dcc1ae59f61beba87d Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 14 Jul 2022 03:44:39 +0200
Subject: De-capitalize aiohttp in codejam frameworks
Co-authored-by: Numerlor <25886452+Numerlor@users.noreply.github.com>
---
 pydis_site/templates/events/pages/code-jams/9/frameworks.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
index 57071463..b462c733 100644
--- a/pydis_site/templates/events/pages/code-jams/9/frameworks.html
+++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html
@@ -71,8 +71,8 @@
     
         
             
-                
AioHTTP
-                
AioHTTP provides both a client and server WebSocket implementation, while avoiding callback-hell.
+                
aiohttp
+                
aiohttp provides both a client and server WebSocket implementation, while avoiding callback-hell.
                 
              
          
-- 
cgit v1.2.3
From 124c84b9e4f8195485b6ff9b3896cc87e640e02b Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Thu, 14 Jul 2022 05:57:05 +0400
Subject: Clean Up Artifact Tests
Signed-off-by: Hassan Abouelela 
---
 pydis_site/apps/api/tests/test_github_utils.py | 16 +++++++---------
 1 file changed, 7 insertions(+), 9 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py
index 78f2979d..a9eab9a5 100644
--- a/pydis_site/apps/api/tests/test_github_utils.py
+++ b/pydis_site/apps/api/tests/test_github_utils.py
@@ -21,7 +21,7 @@ class GeneralUtilityTests(unittest.TestCase):
             """
             Intercept the encode method.
 
-            It is performed with an algorithm which does not require a PEM key, as it may
+            The result is encoded with an algorithm which does not require a PEM key, as it may
             not be available in testing environments.
             """
             self.assertEqual("RS256", algorithm, "The GitHub App JWT must be signed using RS256.")
@@ -39,10 +39,11 @@ class GeneralUtilityTests(unittest.TestCase):
         self.assertLess(decoded["exp"], (datetime.datetime.now() + delta).timestamp())
 
 
-class WaitForTests(unittest.TestCase):
+class CheckRunTests(unittest.TestCase):
     """Tests the check_run_status utility."""
 
     def test_completed_run(self):
+        """Test that an already completed run returns the correct URL."""
         final_url = "some_url_string_1234"
 
         result = github_utils.check_run_status({
@@ -245,20 +246,17 @@ class ArtifactFetcherTests(unittest.TestCase):
 class GitHubArtifactViewTests(django.test.TestCase):
     """Test the GitHub artifact fetch API view."""
 
-    @classmethod
-    def setUpClass(cls):
-        super().setUpClass()
-
-        cls.kwargs = {
+    def setUp(self):
+        self.kwargs = {
             "owner": "test_owner",
             "repo": "test_repo",
             "sha": "test_sha",
             "action_name": "test_action",
             "artifact_name": "test_artifact",
         }
-        cls.url = reverse("api:github-artifacts", kwargs=cls.kwargs)
+        self.url = reverse("api:github-artifacts", kwargs=self.kwargs)
 
-    def test_successful(self, artifact_mock: mock.Mock):
+    def test_correct_artifact(self, artifact_mock: mock.Mock):
         """Test a proper response is returned with proper input."""
         artifact_mock.return_value = "final download url"
         result = self.client.get(self.url)
-- 
cgit v1.2.3
From 675843ea18799da30ca9b01647b55ae2fa7b5ede Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 14 Jul 2022 04:14:21 +0200
Subject: Strike over passed dates in codejam information
---
 pydis_site/templates/events/pages/code-jams/9/_index.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/_index.html b/pydis_site/templates/events/pages/code-jams/9/_index.html
index 7c57b799..f8d6e88b 100644
--- a/pydis_site/templates/events/pages/code-jams/9/_index.html
+++ b/pydis_site/templates/events/pages/code-jams/9/_index.html
@@ -24,8 +24,8 @@
     
         Saturday, June 18 - Form to submit theme suggestions opens 
         Wednesday, June 29 - The Qualifier is released 
-        - Wednesday, July 6 - Voting for the theme opens
 
-        - Wednesday, July 13 - The Qualifier closes
 
+        Wednesday, July 6 - Voting for the theme opens 
+        Wednesday, July 13 - The Qualifier closes 
         - Thursday, July 21 - Code Jam Begins
 
         - Sunday, July 31 - Coding portion of the jam ends
 
         - Sunday, August 4 - Code Jam submissions are closed
 
-- 
cgit v1.2.3
From 0df412364e619b44e1a266b8e372c57fcc8f93ed Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 14 Jul 2022 04:15:05 +0200
Subject: Remove notification about open codejam qualifier
---
 pydis_site/templates/events/index.html                  | 3 ---
 pydis_site/templates/events/pages/code-jams/_index.html | 6 ------
 2 files changed, 9 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/index.html b/pydis_site/templates/events/index.html
index db3e32f7..796a2e34 100644
--- a/pydis_site/templates/events/index.html
+++ b/pydis_site/templates/events/index.html
@@ -9,9 +9,6 @@
 {% block event_content %}
     
         
-        
-          The 
2022 Summer Code Jam is currently underway and you can still enter! 
The qualifier is open until July 13; check out the details 
here.
-        
 
         Every year we hold a community-wide Summer Code Jam. For this event, members of our community are assigned to teams to collaborate and create something amazing using a technology we picked for them. One such technology that was picked for the Summer 2021 Code Jam was text user interfaces (TUIs), where teams could pick from a pre-approved list of frameworks.
         To help fuel the creative process, we provide a specific theme, like Think Inside the Box or Early Internet. At the end of the Code Jam, the projects are judged by Python Discord server staff members and guest judges from the larger Python community. The judges will consider creativity, code quality, teamwork, and adherence to the theme.
         If you want to read more about Code Jams, visit our Code Jam info page or watch this video showcasing the best projects created during the Winter Code Jam 2020: Ancient Technology:
diff --git a/pydis_site/templates/events/pages/code-jams/_index.html b/pydis_site/templates/events/pages/code-jams/_index.html
index 74efcfaa..c7975679 100644
--- a/pydis_site/templates/events/pages/code-jams/_index.html
+++ b/pydis_site/templates/events/pages/code-jams/_index.html
@@ -8,12 +8,6 @@
 {% block title %}Code Jams{% endblock %}
 
 {% block event_content %}
-    
-        
-            The 
2022 Summer Code Jam is currently underway and you can still enter! 
The qualifier is open until July 13; check out the details 
here.
-        
-    
-
     
         If you've been around the server for a while, or you just happened to join at the right time,
         you may have heard of something known as a Code Jam.
-- 
cgit v1.2.3
From da38a0cf766e8e9f77d93b8cbda958306b7b980b Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 14 Jul 2022 18:20:52 +0200
Subject: Update site banner for codejam
---
 pydis_site/templates/home/index.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/home/index.html b/pydis_site/templates/home/index.html
index cdbac830..cf6ff8cd 100644
--- a/pydis_site/templates/home/index.html
+++ b/pydis_site/templates/home/index.html
@@ -12,7 +12,7 @@
   
   
 
@@ -48,7 +48,7 @@
           {# Code Jam Banner #}
           
         
 
-- 
cgit v1.2.3
From e8390858bc8a76dcc3b2c46da02a37860a1dfa84 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 14 Jul 2022 19:20:28 +0200
Subject: Remove accidental stray parenthesis in new codejam rule
Co-authored-by: Kieran Siek 
---
 pydis_site/templates/events/pages/code-jams/9/rules.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/rules.html b/pydis_site/templates/events/pages/code-jams/9/rules.html
index a88c795a..e7a85c70 100644
--- a/pydis_site/templates/events/pages/code-jams/9/rules.html
+++ b/pydis_site/templates/events/pages/code-jams/9/rules.html
@@ -18,7 +18,7 @@
         
             Exceptions to this rule are made for resources such as databases and files, albeit excluding usage other than for storage.
             For example, you may use PostgreSQL as a database but not its `NOTIFY` command.
-            Lastly, working with subprocesses (through stdin/stdout or multiprocessing.Pool()/concurrent.futures.ProcessPoolExecutor()() is also exempted from this rule.
+            Lastly, working with subprocesses (through stdin/stdout or multiprocessing.Pool()/concurrent.futures.ProcessPoolExecutor()) is also exempted from this rule.
         
     
     Your solution should be platform agnostic. For example, if you use filepaths in your submission, use pathlib to create platform agnostic Path objects instead of hardcoding the paths.
 
-- 
cgit v1.2.3
From 974c554da608c0e6c98fbee4beb153b305819c00 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Fri, 15 Jul 2022 01:26:07 +0200
Subject: Reword new codejam rule exception clause
Co-authored-by: wookie184 
---
 pydis_site/templates/events/pages/code-jams/9/rules.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/rules.html b/pydis_site/templates/events/pages/code-jams/9/rules.html
index e7a85c70..cabdd8f4 100644
--- a/pydis_site/templates/events/pages/code-jams/9/rules.html
+++ b/pydis_site/templates/events/pages/code-jams/9/rules.html
@@ -16,9 +16,9 @@
         Your solution must only communicate through WebSockets. Any communication between the client and server (or any two server workers), must utilize WebSockets.
 
         
-            Exceptions to this rule are made for resources such as databases and files, albeit excluding usage other than for storage.
+            An exception to this rule is that communication with databases and files is allowed for accessing resources or for storage purposes. 
             For example, you may use PostgreSQL as a database but not its `NOTIFY` command.
-            Lastly, working with subprocesses (through stdin/stdout or multiprocessing.Pool()/concurrent.futures.ProcessPoolExecutor()) is also exempted from this rule.
+            Working with subprocesses (through stdin/stdout or multiprocessing.Pool()/concurrent.futures.ProcessPoolExecutor()) is also allowed.
         
     
     Your solution should be platform agnostic. For example, if you use filepaths in your submission, use pathlib to create platform agnostic Path objects instead of hardcoding the paths.
 
-- 
cgit v1.2.3
From 0d86b4035fbd5ec65620e7680189828971155edb Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Fri, 15 Jul 2022 01:31:29 +0200
Subject: Add note about approval for non-WebSocket communication
---
 pydis_site/templates/events/pages/code-jams/9/rules.html | 2 ++
 1 file changed, 2 insertions(+)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/rules.html b/pydis_site/templates/events/pages/code-jams/9/rules.html
index cabdd8f4..263e84e1 100644
--- a/pydis_site/templates/events/pages/code-jams/9/rules.html
+++ b/pydis_site/templates/events/pages/code-jams/9/rules.html
@@ -20,6 +20,8 @@
             For example, you may use PostgreSQL as a database but not its `NOTIFY` command.
             Working with subprocesses (through stdin/stdout or multiprocessing.Pool()/concurrent.futures.ProcessPoolExecutor()) is also allowed.
         
+
+        If you're interested in utilizing a particular non-WebSocket method of communication, reach out to the Events Team for discussion and approval
     
     Your solution should be platform agnostic. For example, if you use filepaths in your submission, use pathlib to create platform agnostic Path objects instead of hardcoding the paths.
 
     - 
-- 
cgit v1.2.3
From 5ad1c2f5e8a5352eebdbb4da996506cbeb0dfc0b Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Fri, 15 Jul 2022 02:43:24 +0200
Subject: Remove trailing space in codejam rules
---
 pydis_site/templates/events/pages/code-jams/9/rules.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/rules.html b/pydis_site/templates/events/pages/code-jams/9/rules.html
index 263e84e1..5ad75d67 100644
--- a/pydis_site/templates/events/pages/code-jams/9/rules.html
+++ b/pydis_site/templates/events/pages/code-jams/9/rules.html
@@ -16,7 +16,7 @@
         
Your solution must only communicate through WebSockets. Any communication between the client and server (or any two server workers), must utilize WebSockets.
 
         
-            An exception to this rule is that communication with databases and files is allowed for accessing resources or for storage purposes. 
+            An exception to this rule is that communication with databases and files is allowed for accessing resources or for storage purposes.
             For example, you may use PostgreSQL as a database but not its `NOTIFY` command.
             Working with subprocesses (through stdin/stdout or multiprocessing.Pool()/concurrent.futures.ProcessPoolExecutor()) is also allowed.
         
-- 
cgit v1.2.3
From 1a47cc9390bc08f033f677fd1238f5ef437d7071 Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Sun, 17 Jul 2022 18:28:25 +0200
Subject: Copy over qualifier description from codejam 8
---
 .../templates/events/pages/code-jams/9/_index.html     | 18 ++++++++++++++----
 1 file changed, 14 insertions(+), 4 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/_index.html b/pydis_site/templates/events/pages/code-jams/9/_index.html
index f8d6e88b..8624bfbb 100644
--- a/pydis_site/templates/events/pages/code-jams/9/_index.html
+++ b/pydis_site/templates/events/pages/code-jams/9/_index.html
@@ -30,12 +30,22 @@
         - Sunday, July 31 - Coding portion of the jam ends
 
         - Sunday, August 4 - Code Jam submissions are closed
 
      
-    
+
+    
+    
+        The qualifier is a coding challenge that you are required to complete before registering for the code jam.
+        This is meant as a basic assessment of your skills to ensure you have enough python knowledge to effectively contribute in a team environment.
+    
+    View the Qualifier
     
-        Before being able to join the code jam, you must complete a qualifier which tests your knowledge in Python.
-        The qualifier can be found on our GitHub
-        and once completed you should submit your solution using the sign-up form.
+        Please note the requirements for the qualifier.
+        
+            - The qualifier must be completed using Python 3.10
 
+            - No external modules are allowed, only those available through the standard library.
 
+            - The Qualifier must be submitted through the Code Jam sign-up form.
 
+        
     
+
     
     
         The chosen technology/tech stack for this year is WebSockets.
-- 
cgit v1.2.3
From 2d4ba1aa119b6a740ce103d7df39fb0492322252 Mon Sep 17 00:00:00 2001
From: Exenifix <89513380+Exenifix@users.noreply.github.com>
Date: Mon, 18 Jul 2022 23:24:03 +0300
Subject: Create docker-hosting-guide.md
---
 .../guides/python-guides/docker-hosting-guide.md   | 194 +++++++++++++++++++++
 1 file changed, 194 insertions(+)
 create mode 100644 pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md
new file mode 100644
index 00000000..3ae732e9
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md
@@ -0,0 +1,194 @@
+## Contents
+1. [You will learn](#you-will-learn)
+2. [Introduction](#introduction)
+3. [Installing Docker](#installing-docker)
+4. [Creating Dockerfile](#creating-dockerfile)
+5. [Building Image and Running Container](#building-image-and-running-container)
+6. [Creating Volumes](#creating-volumes)
+7. [Using GitHub Actions for full automation](#using-github-actions-for-full-automation)
+
+## You will learn
+- how to write Dockerfile
+- how to build Docker image and run the container
+- how to use docker-compose
+- how to make docker keep the files throughout the container's runs
+- how to parse environment variables into container
+- how to use GitHub Actions for automation
+- how to setup self hosted runner
+- how to use runner secrets
+
+## Introduction
+Let's say you have got a nice discord bot written in python and you have a VPS to host it on. Now the only question is how to run it 24/7. You might have been suggested to use *screen multiplexer*, but it has some disadvantages:
+1. Every time you update the bot you have to SSH to your server, attach to screen, shutdown the bot, run `git pull` and run the bot again. You might have good extensions management that allows you to update the bot without restarting it, but there are some other cons as well
+2. If you update some dependencies, you have to update them manually
+3. The bot doesn't run in an isolated environment, which is not good for security
+
+But there's a nice and easy solution to these problems - **Docker**! Docker is a containerization utility that automates some stuff like dependencies update and running the application in the background. So let's get started.
+
+## Installing Docker
+The best way to install the docker is to use the [convenience script](https://docs.docker.com/engine/install/ubuntu/#install-using-the-convenience-script) provided by Docker developers themselves. You just need 2 lines:
+```shell
+$ curl -fsSL https://get.docker.com -o get-docker.sh
+$ sudo sh get-docker.sh
+```
+
+## Creating Dockerfile
+To tell Docker what it has to do to run the application, we need to create a file named `Dockerfile` in our project's root.
+
+1. First we need to specify the *base image*. Doing that will make Docker install some apps we need to run our bot, for example the Python interpreter
+```dockerfile
+FROM python:3.10-bullseye
+```
+2. Next, we need to copy our requirements to some directory *inside the container*. Let's call it `/app`
+```dockerfile
+COPY requirements.txt /app/
+```
+3. Now we need to set the directory as working and install the requirements
+```dockerfile
+WORKDIR /app
+RUN pip install -r requirements.txt
+```
+4. The only thing that is left to do is to copy the rest of project's files and run the main executable
+```dockerfile
+COPY . .
+CMD ["python3", "main.py"]
+```
+
+The final version of Dockerfile looks like this:
+```dockerfile
+FROM python:3.10-bullseye
+COPY requirements.txt /app/
+WORKDIR /app
+RUN pip install -r requirements.txt
+COPY . .
+CMD ["python3", "main.py"]
+```
+
+## Building Image and Running Container
+Now update the project on your VPS and we can run the bot with Docker.
+1. Build the image (dot at the end is very important)
+```shell
+$ docker build -t mybot .
+```
+2. Run the container
+```shell
+$ docker run -d --name mybot mybot:latest
+```
+3. Read bot logs (keep in mind that this utility only allows to read STDERR)
+```shell
+$ docker logs -f mybot
+```
+If everything went successfully, your bot will go online and will keep running!
+
+## Using docker-compose
+Just 2 commands to run a container is cool but we can shorten it down to just 1 simple command. For that, create a `docker-compose.yml` file in project's root and fill it with the following contents:
+```yml
+version: "3.8"
+services:
+  main:
+    build: .
+    container-name: mybot
+```
+Update the project on VPS, remove the previous container with `docker rm -f mybot` and run this command
+```shell
+docker-compose up -d --build
+```
+Now the docker will automatically build the image for you and run the container.
+
+## Creating Volumes
+The files creating during container run are destroyed after its recreation. To prevent some files from getting destroyed, we need to use *volumes* that basically save the files from directory inside of container somewhere on drive.
+1. Create a new directory somewhere and copy path to it
+```shell
+$ mkdir mybot-data && echo $(pwd)/mybot-data
+```
+My path is `/home/exenifix/mybot-data`, yours is most likely different.
+2. In your project, store the files that need to be persistant in a separate directory (eg. `data`)
+3. Add the `volumes` construction to `docker-compose` so it looks like this:
+```yml
+version: "3.8"
+services:
+  main:
+    build: .
+    container-name: mybot
+    volumes:
+      - /home/exenifix/mybot-data:/app/data
+```
+The path before the colon `:` is the directory *on drive* and the second path is the directory *inside of container*. All the files saved in container in that directory will be saved on drive's directory as well and Docker will be accessing them *from drive*.
+
+## Using GitHub Actions for full automation
+Now it's time to fully automate the process and make Docker update the bot automatically on every commit or release. For that, we will use a **GitHub Actions workflow**, which basically runs some commands when we need to. You may read more about them [here](https://docs.github.com/en/actions/using-workflows).
+
+### Create repository secret
+We will not have the ability to use `.env` files with the workflow, so it's better to store the environment variables as **actions secrets**.
+1. Head to your repository page -> Settings -> Secrets -> Actions
+2. Press `New repository secret`
+3. Give it a name like `TOKEN` and paste the value
+Now we will be able to access its value in workflow like `${{ secrets.TOKEN }}`. However, we also need to parse the variable into container now. Edit `docker-compose` so it looks like this:
+```yml
+version: "3.8"
+services:
+  main:
+    build: .
+    container-name: mybot
+    volumes:
+      - /home/exenifix/mybot-data:/app/data
+    environment:
+      - TOKEN
+```
+
+### Setup self-hosted runner
+To run the workflow on our VPS, we will need to register it as *self hosted runner*.
+1. Head to Settings -> Actions -> Runners
+2. Press `New self-hosted runner`
+3. Select runner image and architecture
+4. Follow the instructions but don't run the runner
+5. Instead, create a service
+```shell
+$ sudo ./svc.sh install
+$ sudo ./svc.sh start
+```
+Now we have registered our VPS as a self-hosted runner and we can run the workflow on it now.
+
+### Write a workflow
+Create a new file `.github/workflows/runner.yml` and paste the following content into it (it is easy to understand so I am not going to give many comments)
+```yml
+name: Docker Runner
+
+on:
+  push:
+    branches: [ master ]
+
+jobs:
+  run:
+    runs-on: self-hosted
+    environment: production
+
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Run Container
+        run: docker-compose up -d --build
+        env:
+          TOKEN: ${{ secrets.TOKEN }}
+
+      - name: Cleanup Unused Images
+        run: docker image prune -f
+```
+
+Run `docker rm -f mybot` (it only needs to be done once) and push to GitHub. Now if you open `Actions` tab on your repository, you should see a workflow running your bot. Congratulations!
+
+### Displaying logs in actions terminal
+There's a nice utility for reading docker container's logs and stopping upon meeting a certain phrase and it might be useful for you as well.
+1. Install the utility on your VPS with
+```shell
+$ pip install exendlr
+```
+2. Add a step to your workflow that would show the logs until it meets `"ready"` phrase. I recommend putting it before the cleanup.
+```yml
+- name: Display Logs
+  run: python3 -m exendlr mybot "ready"
+```
+Now you should see the logs of your bot until the stop phrase is met. 
+
+**WARNING**
+> The utility only reads from STDERR and redirects to STDERR, if you are using STDOUT for logs, it will not work and will be waiting for stop phrase forever. The utility automatically exits if bot's container is stopped (eg. error occured during starting) or if a log line contains a stop phrase. Make sure that your bot 100% displays a stop phrase when it's ready otherwise your workflow will get stuck.
-- 
cgit v1.2.3
From 2f7aecada0165428017b24baf03ba0a95049a932 Mon Sep 17 00:00:00 2001
From: Exenifix 
Date: Mon, 18 Jul 2022 23:34:51 +0300
Subject: Updated a docker guide
---
 .../guides/python-guides/docker-hosting-guide.md   | 123 +++++++++++++++++----
 1 file changed, 101 insertions(+), 22 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md
index 3ae732e9..b6735586 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md
@@ -1,4 +1,10 @@
+---
+title: How to host a bot with Docker and GitHub Actions on Ubuntu VPS
+description: This guide shows how to host a bot with Docker and GitHub Actions on Ubuntu VPS
+---
+
 ## Contents
+
 1. [You will learn](#you-will-learn)
 2. [Introduction](#introduction)
 3. [Installing Docker](#installing-docker)
@@ -8,53 +14,75 @@
 7. [Using GitHub Actions for full automation](#using-github-actions-for-full-automation)
 
 ## You will learn
+
 - how to write Dockerfile
 - how to build Docker image and run the container
 - how to use docker-compose
 - how to make docker keep the files throughout the container's runs
 - how to parse environment variables into container
 - how to use GitHub Actions for automation
-- how to setup self hosted runner
+- how to setup self-hosted runner
 - how to use runner secrets
 
 ## Introduction
-Let's say you have got a nice discord bot written in python and you have a VPS to host it on. Now the only question is how to run it 24/7. You might have been suggested to use *screen multiplexer*, but it has some disadvantages:
-1. Every time you update the bot you have to SSH to your server, attach to screen, shutdown the bot, run `git pull` and run the bot again. You might have good extensions management that allows you to update the bot without restarting it, but there are some other cons as well
+
+Let's say you have got a nice discord bot written in python and you have a VPS to host it on. Now the only question is
+how to run it 24/7. You might have been suggested to use *screen multiplexer*, but it has some disadvantages:
+
+1. Every time you update the bot you have to SSH to your server, attach to screen, shutdown the bot, run `git pull` and
+   run the bot again. You might have good extensions management that allows you to update the bot without restarting it,
+   but there are some other cons as well
 2. If you update some dependencies, you have to update them manually
 3. The bot doesn't run in an isolated environment, which is not good for security
 
-But there's a nice and easy solution to these problems - **Docker**! Docker is a containerization utility that automates some stuff like dependencies update and running the application in the background. So let's get started.
+But there's a nice and easy solution to these problems - **Docker**! Docker is a containerization utility that automates
+some stuff like dependencies update and running the application in the background. So let's get started.
 
 ## Installing Docker
-The best way to install the docker is to use the [convenience script](https://docs.docker.com/engine/install/ubuntu/#install-using-the-convenience-script) provided by Docker developers themselves. You just need 2 lines:
+
+The best way to install the docker is to use
+the [convenience script](https://docs.docker.com/engine/install/ubuntu/#install-using-the-convenience-script) provided
+by Docker developers themselves. You just need 2 lines:
+
 ```shell
 $ curl -fsSL https://get.docker.com -o get-docker.sh
 $ sudo sh get-docker.sh
 ```
 
 ## Creating Dockerfile
-To tell Docker what it has to do to run the application, we need to create a file named `Dockerfile` in our project's root.
 
-1. First we need to specify the *base image*. Doing that will make Docker install some apps we need to run our bot, for example the Python interpreter
+To tell Docker what it has to do to run the application, we need to create a file named `Dockerfile` in our project's
+root.
+
+1. First we need to specify the *base image*. Doing that will make Docker install some apps we need to run our bot, for
+   example the Python interpreter
+
 ```dockerfile
 FROM python:3.10-bullseye
 ```
+
 2. Next, we need to copy our requirements to some directory *inside the container*. Let's call it `/app`
+
 ```dockerfile
 COPY requirements.txt /app/
 ```
+
 3. Now we need to set the directory as working and install the requirements
+
 ```dockerfile
 WORKDIR /app
 RUN pip install -r requirements.txt
 ```
+
 4. The only thing that is left to do is to copy the rest of project's files and run the main executable
+
 ```dockerfile
 COPY . .
 CMD ["python3", "main.py"]
 ```
 
 The final version of Dockerfile looks like this:
+
 ```dockerfile
 FROM python:3.10-bullseye
 COPY requirements.txt /app/
@@ -65,71 +93,103 @@ CMD ["python3", "main.py"]
 ```
 
 ## Building Image and Running Container
+
 Now update the project on your VPS and we can run the bot with Docker.
+
 1. Build the image (dot at the end is very important)
+
 ```shell
 $ docker build -t mybot .
 ```
+
 2. Run the container
+
 ```shell
 $ docker run -d --name mybot mybot:latest
 ```
+
 3. Read bot logs (keep in mind that this utility only allows to read STDERR)
+
 ```shell
 $ docker logs -f mybot
 ```
+
 If everything went successfully, your bot will go online and will keep running!
 
 ## Using docker-compose
-Just 2 commands to run a container is cool but we can shorten it down to just 1 simple command. For that, create a `docker-compose.yml` file in project's root and fill it with the following contents:
+
+Just 2 commands to run a container is cool but we can shorten it down to just 1 simple command. For that, create
+a `docker-compose.yml` file in project's root and fill it with the following contents:
+
 ```yml
 version: "3.8"
 services:
   main:
     build: .
-    container-name: mybot
+    container_name: mybot
 ```
+
 Update the project on VPS, remove the previous container with `docker rm -f mybot` and run this command
+
 ```shell
 docker-compose up -d --build
 ```
+
 Now the docker will automatically build the image for you and run the container.
 
 ## Creating Volumes
-The files creating during container run are destroyed after its recreation. To prevent some files from getting destroyed, we need to use *volumes* that basically save the files from directory inside of container somewhere on drive.
+
+The files creating during container run are destroyed after its recreation. To prevent some files from getting
+destroyed, we need to use *volumes* that basically save the files from directory inside of container somewhere on drive.
+
 1. Create a new directory somewhere and copy path to it
+
 ```shell
 $ mkdir mybot-data && echo $(pwd)/mybot-data
 ```
+
 My path is `/home/exenifix/mybot-data`, yours is most likely different.
+
 2. In your project, store the files that need to be persistant in a separate directory (eg. `data`)
 3. Add the `volumes` construction to `docker-compose` so it looks like this:
+
 ```yml
 version: "3.8"
 services:
   main:
     build: .
-    container-name: mybot
+    container_name: mybot
     volumes:
       - /home/exenifix/mybot-data:/app/data
 ```
-The path before the colon `:` is the directory *on drive* and the second path is the directory *inside of container*. All the files saved in container in that directory will be saved on drive's directory as well and Docker will be accessing them *from drive*.
+
+The path before the colon `:` is the directory *on drive* and the second path is the directory *inside of container*.
+All the files saved in container in that directory will be saved on drive's directory as well and Docker will be
+accessing them *from drive*.
 
 ## Using GitHub Actions for full automation
-Now it's time to fully automate the process and make Docker update the bot automatically on every commit or release. For that, we will use a **GitHub Actions workflow**, which basically runs some commands when we need to. You may read more about them [here](https://docs.github.com/en/actions/using-workflows).
+
+Now it's time to fully automate the process and make Docker update the bot automatically on every commit or release. For
+that, we will use a **GitHub Actions workflow**, which basically runs some commands when we need to. You may read more
+about them [here](https://docs.github.com/en/actions/using-workflows).
 
 ### Create repository secret
-We will not have the ability to use `.env` files with the workflow, so it's better to store the environment variables as **actions secrets**.
+
+We will not have the ability to use `.env` files with the workflow, so it's better to store the environment variables
+as **actions secrets**.
+
 1. Head to your repository page -> Settings -> Secrets -> Actions
 2. Press `New repository secret`
 3. Give it a name like `TOKEN` and paste the value
-Now we will be able to access its value in workflow like `${{ secrets.TOKEN }}`. However, we also need to parse the variable into container now. Edit `docker-compose` so it looks like this:
+   Now we will be able to access its value in workflow like `${{ secrets.TOKEN }}`. However, we also need to parse the
+   variable into container now. Edit `docker-compose` so it looks like this:
+
 ```yml
 version: "3.8"
 services:
   main:
     build: .
-    container-name: mybot
+    container_name: mybot
     volumes:
       - /home/exenifix/mybot-data:/app/data
     environment:
@@ -137,20 +197,27 @@ services:
 ```
 
 ### Setup self-hosted runner
+
 To run the workflow on our VPS, we will need to register it as *self hosted runner*.
+
 1. Head to Settings -> Actions -> Runners
 2. Press `New self-hosted runner`
 3. Select runner image and architecture
 4. Follow the instructions but don't run the runner
 5. Instead, create a service
+
 ```shell
 $ sudo ./svc.sh install
 $ sudo ./svc.sh start
 ```
+
 Now we have registered our VPS as a self-hosted runner and we can run the workflow on it now.
 
 ### Write a workflow
-Create a new file `.github/workflows/runner.yml` and paste the following content into it (it is easy to understand so I am not going to give many comments)
+
+Create a new file `.github/workflows/runner.yml` and paste the following content into it (it is easy to understand so I
+am not going to give many comments)
+
 ```yml
 name: Docker Runner
 
@@ -175,20 +242,32 @@ jobs:
         run: docker image prune -f
 ```
 
-Run `docker rm -f mybot` (it only needs to be done once) and push to GitHub. Now if you open `Actions` tab on your repository, you should see a workflow running your bot. Congratulations!
+Run `docker rm -f mybot` (it only needs to be done once) and push to GitHub. Now if you open `Actions` tab on your
+repository, you should see a workflow running your bot. Congratulations!
 
 ### Displaying logs in actions terminal
-There's a nice utility for reading docker container's logs and stopping upon meeting a certain phrase and it might be useful for you as well.
+
+There's a nice utility for reading docker container's logs and stopping upon meeting a certain phrase and it might be
+useful for you as well.
+
 1. Install the utility on your VPS with
+
 ```shell
 $ pip install exendlr
 ```
-2. Add a step to your workflow that would show the logs until it meets `"ready"` phrase. I recommend putting it before the cleanup.
+
+2. Add a step to your workflow that would show the logs until it meets `"ready"` phrase. I recommend putting it before
+   the cleanup.
+
 ```yml
 - name: Display Logs
   run: python3 -m exendlr mybot "ready"
 ```
-Now you should see the logs of your bot until the stop phrase is met. 
+
+Now you should see the logs of your bot until the stop phrase is met.
 
 **WARNING**
-> The utility only reads from STDERR and redirects to STDERR, if you are using STDOUT for logs, it will not work and will be waiting for stop phrase forever. The utility automatically exits if bot's container is stopped (eg. error occured during starting) or if a log line contains a stop phrase. Make sure that your bot 100% displays a stop phrase when it's ready otherwise your workflow will get stuck.
+> The utility only reads from STDERR and redirects to STDERR, if you are using STDOUT for logs, it will not work and
+> will be waiting for stop phrase forever. The utility automatically exits if bot's container is stopped (eg. error
+> occured during starting) or if a log line contains a stop phrase. Make sure that your bot 100% displays a stop phrase
+> when it's ready otherwise your workflow will get stuck.
-- 
cgit v1.2.3
From 473c96a8353e5bea5a639657fac8058d8f44033a Mon Sep 17 00:00:00 2001
From: mina <75038675+minalike@users.noreply.github.com>
Date: Tue, 19 Jul 2022 01:01:44 -0400
Subject: Add back green box link to CJ 9 page
This was the easiest way of navigating to the CJ9 page previously other than the home page. Let's keep it for the duration of the jam
---
 pydis_site/templates/events/index.html | 3 +++
 1 file changed, 3 insertions(+)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/index.html b/pydis_site/templates/events/index.html
index 796a2e34..640682d0 100644
--- a/pydis_site/templates/events/index.html
+++ b/pydis_site/templates/events/index.html
@@ -9,6 +9,9 @@
 {% block event_content %}
     
         
+        
         Every year we hold a community-wide Summer Code Jam. For this event, members of our community are assigned to teams to collaborate and create something amazing using a technology we picked for them. One such technology that was picked for the Summer 2021 Code Jam was text user interfaces (TUIs), where teams could pick from a pre-approved list of frameworks.
         To help fuel the creative process, we provide a specific theme, like Think Inside the Box or Early Internet. At the end of the Code Jam, the projects are judged by Python Discord server staff members and guest judges from the larger Python community. The judges will consider creativity, code quality, teamwork, and adherence to the theme.
         If you want to read more about Code Jams, visit our Code Jam info page or watch this video showcasing the best projects created during the Winter Code Jam 2020: Ancient Technology:
-- 
cgit v1.2.3
From 6a1ab5b856200afc4d98703b07a5aceb74051c1c Mon Sep 17 00:00:00 2001
From: mina <75038675+minalike@users.noreply.github.com>
Date: Tue, 19 Jul 2022 01:03:19 -0400
Subject: Add CJ9 page link to side bar
Technically it's an ongoing jam, not a previous jam. But I think adding this link will help with discoverability if you're already on the CJ Info page. Can be updated once a theme is announced
---
 pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html | 1 +
 1 file changed, 1 insertion(+)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html b/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html
index 21b2ccb4..4493ed43 100644
--- a/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html
+++ b/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html
@@ -1,6 +1,7 @@
 
     
     
-- 
cgit v1.2.3
From fadda91a5e1733751cc1f68973803d931f2b189f Mon Sep 17 00:00:00 2001
From: Bluenix 
Date: Thu, 21 Jul 2022 23:56:15 +0200
Subject: Update codejams sidebar about cj9 theme announcement
---
 pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html b/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html
index 4493ed43..28412c53 100644
--- a/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html
+++ b/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html
@@ -1,7 +1,7 @@
 
     
     
     
 {% endblock %}
-
-{% block page_content %}
-    {{ block.super }}
-{% endblock %}
-- 
cgit v1.2.3
From 9d73247694e3a97b357b506f83493e96ecf2c4de Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Sun, 14 Aug 2022 05:43:22 +0200
Subject: Change Hyperlink Color On Hover
Signed-off-by: Hassan Abouelela 
---
 pydis_site/static/css/content/tag.css | 4 ++++
 1 file changed, 4 insertions(+)
(limited to 'pydis_site')
diff --git a/pydis_site/static/css/content/tag.css b/pydis_site/static/css/content/tag.css
index ec45bfc7..32a605a8 100644
--- a/pydis_site/static/css/content/tag.css
+++ b/pydis_site/static/css/content/tag.css
@@ -3,3 +3,7 @@
     /* which allows for elements inside links, such as codeblocks */
     color: #7289DA;
 }
+
+.content a *:hover {
+    color: black;
+}
-- 
cgit v1.2.3
From 5aeffa5ab4dd8b251d2ae742d1a1e2bf3ba461c7 Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Sun, 14 Aug 2022 07:14:50 +0200
Subject: Update Tests For Tag Groups
Signed-off-by: Hassan Abouelela 
---
 pydis_site/apps/content/tests/test_utils.py | 110 +++++++++++++++++++++++-----
 pydis_site/apps/content/tests/test_views.py |  68 ++++++++++++++++-
 pydis_site/apps/content/utils.py            |   3 +-
 3 files changed, 157 insertions(+), 24 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py
index a5d5dcb4..556f633c 100644
--- a/pydis_site/apps/content/tests/test_utils.py
+++ b/pydis_site/apps/content/tests/test_utils.py
@@ -132,6 +132,7 @@ class TagUtilsTests(TestCase):
                 ---
                 This tag has frontmatter!
             """),
+            "This is a grouped tag!",
         )
 
         # Generate a tar archive with a few tags
@@ -146,6 +147,10 @@ class TagUtilsTests(TestCase):
                 (tags_folder / "first_tag.md").write_text(bodies[0])
                 (tags_folder / "second_tag.md").write_text(bodies[1])
 
+                group_folder = tags_folder / "some_group"
+                group_folder.mkdir()
+                (group_folder / "grouped_tag.md").write_text(bodies[2])
+
                 with tarfile.open(tar_folder / "temp.tar", "w") as file:
                     file.add(folder, recursive=True)
 
@@ -158,10 +163,15 @@ class TagUtilsTests(TestCase):
         )
 
         result = utils.fetch_tags()
-        self.assertEqual([
+
+        def sort(_tag: models.Tag) -> str:
+            return _tag.name
+
+        self.assertEqual(sorted([
             models.Tag(name="first_tag", body=bodies[0]),
             models.Tag(name="second_tag", body=bodies[1]),
-        ], sorted(result, key=lambda tag: tag.name))
+            models.Tag(name="grouped_tag", body=bodies[2], group=group_folder.name),
+        ], key=sort), sorted(result, key=sort))
 
     def test_get_real_tag(self):
         """Test that a single tag is returned if it exists."""
@@ -170,30 +180,92 @@ class TagUtilsTests(TestCase):
 
         self.assertEqual(tag, result)
 
+    def test_get_grouped_tag(self):
+        """Test fetching a tag from a group."""
+        tag = models.Tag.objects.create(name="real-tag", group="real-group")
+        result = utils.get_tag("real-group/real-tag")
+
+        self.assertEqual(tag, result)
+
+    def test_get_group(self):
+        """Test fetching a group of tags."""
+        included = [
+            models.Tag.objects.create(name="tag-1", group="real-group"),
+            models.Tag.objects.create(name="tag-2", group="real-group"),
+            models.Tag.objects.create(name="tag-3", group="real-group"),
+        ]
+
+        models.Tag.objects.create(name="not-included-1")
+        models.Tag.objects.create(name="not-included-2", group="other-group")
+
+        result = utils.get_tag("real-group")
+        self.assertListEqual(included, result)
+
     def test_get_tag_404(self):
         """Test that an error is raised when we fetch a non-existing tag."""
         models.Tag.objects.create(name="real-tag")
         with self.assertRaises(models.Tag.DoesNotExist):
             utils.get_tag("fake")
 
-    def test_category_pages(self):
-        """Test that the category pages function returns the correct records for tags."""
-        models.Tag.objects.create(name="second-tag", body="Normal body")
-        models.Tag.objects.create(name="first-tag", body="Normal body")
-        tag_body = {"description": markdown.markdown("Normal body"), "icon": "fas fa-tag"}
-
+    @mock.patch.object(utils, "get_tag_category")
+    def test_category_pages(self, get_mock: mock.Mock):
+        """Test that the category pages function calls the correct method for tags."""
+        tag = models.Tag.objects.create(name="tag")
+        get_mock.return_value = tag
         result = utils.get_category_pages(settings.CONTENT_PAGES_PATH / "tags")
+        self.assertEqual(tag, result)
+        get_mock.assert_called_once_with(collapse_groups=True)
+
+    def test_get_category_root(self):
+        """Test that all tags are returned and formatted properly for the tag root page."""
+        body = "normal body"
+        base = {"description": markdown.markdown(body), "icon": "fas fa-tag"}
+
+        models.Tag.objects.create(name="tag-1", body=body),
+        models.Tag.objects.create(name="tag-2", body=body),
+        models.Tag.objects.create(name="tag-3", body=body),
+
+        models.Tag.objects.create(name="tag-4", body=body, group="tag-group")
+        models.Tag.objects.create(name="tag-5", body=body, group="tag-group")
+
+        result = utils.get_tag_category(collapse_groups=True)
+
         self.assertDictEqual({
-            "first-tag": {**tag_body, "title": "first-tag"},
-            "second-tag": {**tag_body, "title": "second-tag"},
+            "tag-1": {**base, "title": "tag-1"},
+            "tag-2": {**base, "title": "tag-2"},
+            "tag-3": {**base, "title": "tag-3"},
+            "tag-group": {
+                "title": "tag-group",
+                "description": "Contains the following tags: tag-4, tag-5",
+                "icon": "fas fa-tags"
+            }
         }, result)
 
-    def test_trimmed_tag_content(self):
-        """Test a tag with a long body that requires trimming."""
-        tag = models.Tag.objects.create(name="long-tag", body="E" * 300)
-        result = utils.get_category_pages(settings.CONTENT_PAGES_PATH / "tags")
-        self.assertDictEqual({"long-tag": {
-            "title": "long-tag",
-            "description": markdown.markdown(tag.body[:100] + "..."),
-            "icon": "fas fa-tag",
-        }}, result)
+    def test_get_category_group(self):
+        """Test the function for a group root page."""
+        body = "normal body"
+        base = {"description": markdown.markdown(body), "icon": "fas fa-tag"}
+
+        included = [
+            models.Tag.objects.create(name="tag-1", body=body, group="group"),
+            models.Tag.objects.create(name="tag-2", body=body, group="group"),
+        ]
+        models.Tag.objects.create(name="not-included", body=body)
+
+        result = utils.get_tag_category(included, collapse_groups=False)
+        self.assertDictEqual({
+            "tag-1": {**base, "title": "tag-1"},
+            "tag-2": {**base, "title": "tag-2"},
+        }, result)
+
+    def test_tag_url(self):
+        """Test that tag URLs are generated correctly."""
+        cases = [
+            ({"name": "tag"}, f"{models.Tag.URL_BASE}/tag.md"),
+            ({"name": "grouped", "group": "abc"}, f"{models.Tag.URL_BASE}/abc/grouped.md"),
+        ]
+
+        for options, url in cases:
+            tag = models.Tag(**options)
+            with self.subTest(tag=tag):
+                self.assertEqual(url, tag.url)
diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py
index c4d3474e..c5c25be4 100644
--- a/pydis_site/apps/content/tests/test_views.py
+++ b/pydis_site/apps/content/tests/test_views.py
@@ -194,6 +194,23 @@ class TagViewTests(django.test.TestCase):
         """Set test helpers, then set up fake filesystem."""
         super().setUp()
 
+    def test_routing(self):
+        """Test that the correct template is returned for each route."""
+        Tag.objects.create(name="example")
+        Tag.objects.create(name="grouped-tag", group="group-name")
+
+        cases = [
+            ("/pages/tags/example/", "content/tag.html"),
+            ("/pages/tags/group-name/", "content/listing.html"),
+            ("/pages/tags/group-name/grouped-tag/", "content/tag.html"),
+        ]
+
+        for url, template in cases:
+            with self.subTest(url=url):
+                response = self.client.get(url)
+                self.assertEqual(200, response.status_code)
+                self.assertTemplateUsed(response, template)
+
     def test_valid_tag_returns_200(self):
         """Test that a page is returned for a valid tag."""
         Tag.objects.create(name="example", body="This is the tag body.")
@@ -207,8 +224,8 @@ class TagViewTests(django.test.TestCase):
         response = self.client.get("/pages/tags/non-existent/")
         self.assertEqual(404, response.status_code)
 
-    def test_context(self):
-        """Check that the context contains all the necessary data."""
+    def test_context_tag(self):
+        """Test that the context contains the required data for a tag."""
         body = textwrap.dedent("""
         ---
         unused: frontmatter
@@ -222,12 +239,55 @@ class TagViewTests(django.test.TestCase):
             "page_title": "example",
             "page": markdown.markdown("Tag content here."),
             "tag": tag,
+            "breadcrumb_items": [
+                {"name": "Pages", "path": "."},
+                {"name": "Tags", "path": "tags"},
+            ]
         }
         for key in expected:
             self.assertEqual(
                 expected[key], response.context.get(key), f"context.{key} did not match"
             )
 
+    def test_context_grouped_tag(self):
+        """
+        Test the context for a tag in a group.
+
+        The only difference between this and a regular tag are the breadcrumbs,
+        so only those are checked.
+        """
+        Tag.objects.create(name="example", body="Body text", group="group-name")
+        response = self.client.get("/pages/tags/group-name/example/")
+        self.assertListEqual([
+            {"name": "Pages", "path": "."},
+            {"name": "Tags", "path": "tags"},
+            {"name": "group-name", "path": "tags/group-name"},
+        ], response.context.get("breadcrumb_items"))
+
+    def test_group_page(self):
+        """Test rendering of a group's root page."""
+        Tag.objects.create(name="tag-1", body="Body 1", group="group-name")
+        Tag.objects.create(name="tag-2", body="Body 2", group="group-name")
+        Tag.objects.create(name="not-included")
+
+        response = self.client.get("/pages/tags/group-name/")
+        content = response.content.decode("utf-8")
+
+        self.assertInHTML("group-name
", content)
+        self.assertInHTML(
+            f"",
+            content
+        )
+        self.assertIn(">tag-1", content)
+        self.assertIn(">tag-2", content)
+        self.assertNotIn(
+            ">not-included",
+            content,
+            "Tags not in this group shouldn't be rendered."
+        )
+
+        self.assertInHTML("Body 1
", content)
+
     def test_markdown(self):
         """Test that markdown content is rendered properly."""
         body = textwrap.dedent("""
@@ -287,7 +347,7 @@ class TagViewTests(django.test.TestCase):
         body = filler_before + "`!tags return`" + filler_after
         Tag.objects.create(name="example", body=body)
 
-        other_url = reverse("content:tag", kwargs={"name": "return"})
+        other_url = reverse("content:tag", kwargs={"location": "return"})
         response = self.client.get("/pages/tags/example/")
         self.assertEqual(
             markdown.markdown(filler_before + f"[`!tags return`]({other_url})" + filler_after),
@@ -304,7 +364,7 @@ class TagViewTests(django.test.TestCase):
         content = response.content.decode("utf-8")
 
         self.assertTemplateUsed(response, "content/listing.html")
-        self.assertInHTML('Tags
', content)
+        self.assertInHTML('Tags
', content)
 
         for tag_number in range(1, 4):
             self.assertIn(f"tag-{tag_number}", content)
diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py
index da6a024d..11100ba5 100644
--- a/pydis_site/apps/content/utils.py
+++ b/pydis_site/apps/content/utils.py
@@ -48,7 +48,7 @@ def get_tags_static() -> list[Tag]:
     This will return a cached value, so it should only be used for static builds.
     """
     tags = fetch_tags()
-    for tag in tags[3:5]:
+    for tag in tags[3:5]:  # pragma: no cover
         tag.group = "very-cool-group"
     return tags
 
@@ -190,6 +190,7 @@ def get_tag_category(
 
     # Flatten group description into a single string
     for group in groups.values():
+        # If the following string is updated, make sure to update it in the frontend JS as well
         group["description"] = "Contains the following tags: " + ", ".join(group["description"])
         data.append(group)
 
-- 
cgit v1.2.3
From 25db8f564c0f5c473b165ccab14413ca4471ac7d Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Sun, 14 Aug 2022 07:20:34 +0200
Subject: Explicitly Specify Infraction Time In Tests
The infraction tests checked that the route returned infractions in the
correct order, which is based on insertion time. This can be fragile
however, since the insertion time can be very close (or identical)
during the tests. That became especially more likely with PR #741
(commit 149e67b4) which improved database access speed.
This is fixed by explicitly specifying the insertion time, and spacing
them out properly.
Signed-off-by: Hassan Abouelela 
---
 pydis_site/apps/api/tests/test_infractions.py | 15 ++++++++++-----
 1 file changed, 10 insertions(+), 5 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py
index f1107734..89ee4e23 100644
--- a/pydis_site/apps/api/tests/test_infractions.py
+++ b/pydis_site/apps/api/tests/test_infractions.py
@@ -56,15 +56,17 @@ class InfractionTests(AuthenticatedAPITestCase):
             type='ban',
             reason='He terk my jerb!',
             hidden=True,
+            inserted_at=dt(2020, 10, 10, 0, 0, 0, tzinfo=timezone.utc),
             expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc),
-            active=True
+            active=True,
         )
         cls.ban_inactive = Infraction.objects.create(
             user_id=cls.user.id,
             actor_id=cls.user.id,
             type='ban',
             reason='James is an ass, and we won\'t be working with him again.',
-            active=False
+            active=False,
+            inserted_at=dt(2020, 10, 10, 0, 1, 0, tzinfo=timezone.utc),
         )
         cls.mute_permanent = Infraction.objects.create(
             user_id=cls.user.id,
@@ -72,7 +74,8 @@ class InfractionTests(AuthenticatedAPITestCase):
             type='mute',
             reason='He has a filthy mouth and I am his soap.',
             active=True,
-            expires_at=None
+            inserted_at=dt(2020, 10, 10, 0, 2, 0, tzinfo=timezone.utc),
+            expires_at=None,
         )
         cls.superstar_expires_soon = Infraction.objects.create(
             user_id=cls.user.id,
@@ -80,7 +83,8 @@ class InfractionTests(AuthenticatedAPITestCase):
             type='superstar',
             reason='This one doesn\'t matter anymore.',
             active=True,
-            expires_at=dt.now(timezone.utc) + datetime.timedelta(hours=5)
+            inserted_at=dt(2020, 10, 10, 0, 3, 0, tzinfo=timezone.utc),
+            expires_at=dt.now(timezone.utc) + datetime.timedelta(hours=5),
         )
         cls.voiceban_expires_later = Infraction.objects.create(
             user_id=cls.user.id,
@@ -88,7 +92,8 @@ class InfractionTests(AuthenticatedAPITestCase):
             type='voice_ban',
             reason='Jet engine mic',
             active=True,
-            expires_at=dt.now(timezone.utc) + datetime.timedelta(days=5)
+            inserted_at=dt(2020, 10, 10, 0, 4, 0, tzinfo=timezone.utc),
+            expires_at=dt.now(timezone.utc) + datetime.timedelta(days=5),
         )
 
     def test_list_all(self):
-- 
cgit v1.2.3
From acd4238fcb9c0135a548eb9bad43923fc41e983e Mon Sep 17 00:00:00 2001
From: Robin <74519799+Robin5605@users.noreply.github.com>
Date: Mon, 15 Aug 2022 17:40:24 -0500
Subject: Migrate on_command_error pin
As part of the migration of the #discord-bots pinned content from discord to the site, this PR migrates the pin regarding `on_command_error` of the discord.py library "eating" (silencing) unhandled errors.
---
 .../guides/python-guides/proper-error-handling.md  | 70 ++++++++++++++++++++++
 1 file changed, 70 insertions(+)
 create mode 100644 pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md
new file mode 100644
index 00000000..9307169d
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md
@@ -0,0 +1,70 @@
+---
+title: Proper error handling in discord.py
+description: Are you not getting any errors? This might be why!
+---
+If you're not recieving any errors in your console, even though you know you should be, try this:
+
+# With bot subclass:
+```py
+import discord
+from discord.ext import commands
+
+import traceback
+import sys
+
+class MyBot(commands.Bot):
+
+  def __init__(self, *args, **kwargs):
+    super().__init__(*args, **kwargs)
+    
+  async def on_command_error(self, ctx: commands.Context, error):
+    # Handle your errors here
+    if isinstance(error, commands.MemberNotFound):
+      await ctx.send("I could not find member '{error.argument}'. Please try again")
+
+    elif isinstance(error, commands.MissingRequiredArgument):
+      await ctx.send(f"'{error.param.name}' is a required argument.")
+    else:
+      # All unhandled errors will print their original traceback
+      print(f'Ignoring exception in command {ctx.command}:', file=sys.stderr)
+      traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
+      
+bot = MyBot(command_prefix="!", intents=discord.Intents.default())
+
+bot.run("token")
+```
+
+# Without bot subclass
+```py
+import discord
+from discord.ext import commands
+
+import traceback
+import sys
+
+async def on_command_error(self, ctx: commands.Context, error):
+  # Handle your errors here
+  if isinstance(error, commands.MemberNotFound):
+    await ctx.send("I could not find member '{error.argument}'. Please try again")
+
+  elif isinstance(error, commands.MissingRequiredArgument):
+    await ctx.send(f"'{error.param.name}' is a required argument.")
+  else:
+    # All unhandled errors will print their original traceback
+    print(f'Ignoring exception in command {ctx.command}:', file=sys.stderr)
+    traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
+
+bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
+bot.on_command_error = on_command_error
+
+bot.run("token")
+```
+
+
+Make sure to import `traceback` and `sys`!
+
+-------------------------------------------------------------------------------------------------------------
+
+Useful Links: 
+- [FAQ](https://discordpy.readthedocs.io/en/latest/faq.html)
+- [Simple Error Handling](https://gist.github.com/EvieePy/7822af90858ef65012ea500bcecf1612)
-- 
cgit v1.2.3
From d64ed9b4d269d9731267c6d7b088555ea3cf4e31 Mon Sep 17 00:00:00 2001
From: Robin <74519799+Robin5605@users.noreply.github.com>
Date: Mon, 15 Aug 2022 17:51:04 -0500
Subject: Update proper-error-handling.md
---
 .../content/resources/guides/python-guides/proper-error-handling.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md
index 9307169d..e0606625 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md
@@ -16,7 +16,7 @@ class MyBot(commands.Bot):
 
   def __init__(self, *args, **kwargs):
     super().__init__(*args, **kwargs)
-    
+
   async def on_command_error(self, ctx: commands.Context, error):
     # Handle your errors here
     if isinstance(error, commands.MemberNotFound):
@@ -28,7 +28,7 @@ class MyBot(commands.Bot):
       # All unhandled errors will print their original traceback
       print(f'Ignoring exception in command {ctx.command}:', file=sys.stderr)
       traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
-      
+
 bot = MyBot(command_prefix="!", intents=discord.Intents.default())
 
 bot.run("token")
@@ -65,6 +65,6 @@ Make sure to import `traceback` and `sys`!
 
 -------------------------------------------------------------------------------------------------------------
 
-Useful Links: 
+Useful Links:
 - [FAQ](https://discordpy.readthedocs.io/en/latest/faq.html)
 - [Simple Error Handling](https://gist.github.com/EvieePy/7822af90858ef65012ea500bcecf1612)
-- 
cgit v1.2.3
From c5e03fbb8f7a4ebc2f75ce9f8099ba9ef1366f1e Mon Sep 17 00:00:00 2001
From: Ibrahim 
Date: Tue, 16 Aug 2022 20:13:21 +0530
Subject: striked passed date
---
 pydis_site/templates/events/pages/code-jams/9/_index.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/templates/events/pages/code-jams/9/_index.html b/pydis_site/templates/events/pages/code-jams/9/_index.html
index 7c2617d7..ca7c4f90 100644
--- a/pydis_site/templates/events/pages/code-jams/9/_index.html
+++ b/pydis_site/templates/events/pages/code-jams/9/_index.html
@@ -27,8 +27,8 @@
         Wednesday, July 6 - Voting for the theme opens 
         Wednesday, July 13 - The Qualifier closes 
         Thursday, July 21 - Code Jam Begins 
-        - Sunday, July 31 - Coding portion of the jam ends
 
-        - Sunday, August 4 - Code Jam submissions are closed
 
+        Sunday, July 31 - Coding portion of the jam ends 
+        Sunday, August 4 - Code Jam submissions are closed 
     
 
     
-- 
cgit v1.2.3
From 5dfe019745b53ceb8ce37f0db937d6e2a302f6d7 Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Tue, 16 Aug 2022 18:58:29 +0400
Subject: Move GitHub strptime Format To Settings
Signed-off-by: Hassan Abouelela 
---
 pydis_site/apps/api/github_utils.py | 4 +---
 pydis_site/settings.py              | 2 ++
 2 files changed, 3 insertions(+), 3 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py
index 5d7bcdc3..e9d7347b 100644
--- a/pydis_site/apps/api/github_utils.py
+++ b/pydis_site/apps/api/github_utils.py
@@ -11,8 +11,6 @@ from pydis_site import settings
 
 MAX_RUN_TIME = datetime.timedelta(minutes=10)
 """The maximum time allowed before an action is declared timed out."""
-ISO_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ"
-"""The datetime string format GitHub uses."""
 
 
 class ArtifactProcessingError(Exception):
@@ -147,7 +145,7 @@ def authorize(owner: str, repo: str) -> httpx.Client:
 
 def check_run_status(run: WorkflowRun) -> str:
     """Check if the provided run has been completed, otherwise raise an exception."""
-    created_at = datetime.datetime.strptime(run.created_at, ISO_FORMAT_STRING)
+    created_at = datetime.datetime.strptime(run.created_at, settings.GITHUB_TIMESTAMP_FORMAT)
     run_time = datetime.datetime.utcnow() - created_at
 
     if run.status != "completed":
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
index 315ea737..9fbd0273 100644
--- a/pydis_site/settings.py
+++ b/pydis_site/settings.py
@@ -38,6 +38,8 @@ GITHUB_API = "https://api.github.com"
 GITHUB_TOKEN = env("GITHUB_TOKEN")
 GITHUB_APP_ID = env("GITHUB_APP_ID")
 GITHUB_APP_KEY = env("GITHUB_APP_KEY")
+GITHUB_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
+"""The datetime string format GitHub uses."""
 
 if GITHUB_APP_KEY and (key_file := Path(GITHUB_APP_KEY)).is_file():
     # Allow the OAuth key to be loaded from a file
-- 
cgit v1.2.3
From f2374900c4c83097c105b56de02ea82d66bd9466 Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Tue, 16 Aug 2022 21:19:53 +0400
Subject: Unify Tag Migrations & Add Commit Model
Signed-off-by: Hassan Abouelela 
---
 .../apps/content/migrations/0001_add_tags.py       | 34 ++++++++++++++++++++
 pydis_site/apps/content/migrations/0001_initial.py | 23 --------------
 .../migrations/0002_remove_tag_url_tag_group.py    | 22 -------------
 pydis_site/apps/content/models/__init__.py         |  4 +--
 pydis_site/apps/content/models/tag.py              | 36 ++++++++++++++++++++++
 5 files changed, 72 insertions(+), 47 deletions(-)
 create mode 100644 pydis_site/apps/content/migrations/0001_add_tags.py
 delete mode 100644 pydis_site/apps/content/migrations/0001_initial.py
 delete mode 100644 pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/migrations/0001_add_tags.py b/pydis_site/apps/content/migrations/0001_add_tags.py
new file mode 100644
index 00000000..2e9d8c45
--- /dev/null
+++ b/pydis_site/apps/content/migrations/0001_add_tags.py
@@ -0,0 +1,34 @@
+# Generated by Django 4.0.6 on 2022-08-16 16:17
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Commit',
+            fields=[
+                ('sha', models.CharField(help_text='The SHA hash of this commit.', max_length=40, primary_key=True, serialize=False)),
+                ('message', models.TextField(help_text='The commit message.')),
+                ('date', models.DateTimeField(help_text='The date and time the commit was created.')),
+                ('author', models.TextField(help_text='The person(s) who created the commit.')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Tag',
+            fields=[
+                ('last_updated', models.DateTimeField(auto_now=True, help_text='The date and time this data was last fetched.')),
+                ('name', models.CharField(help_text="The tag's name.", max_length=50, primary_key=True, serialize=False)),
+                ('group', models.CharField(help_text='The group the tag belongs to.', max_length=50, null=True)),
+                ('body', models.TextField(help_text='The content of the tag.')),
+                ('last_commit', models.OneToOneField(help_text='The commit this file was last touched in.', null=True, on_delete=django.db.models.deletion.CASCADE, to='content.commit')),
+            ],
+        ),
+    ]
diff --git a/pydis_site/apps/content/migrations/0001_initial.py b/pydis_site/apps/content/migrations/0001_initial.py
deleted file mode 100644
index 15e3fc95..00000000
--- a/pydis_site/apps/content/migrations/0001_initial.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# Generated by Django 4.0.6 on 2022-08-13 00:53
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    initial = True
-
-    dependencies = [
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='Tag',
-            fields=[
-                ('last_updated', models.DateTimeField(auto_now=True, help_text='The date and time this data was last fetched.')),
-                ('name', models.CharField(help_text="The tag's name.", max_length=50, primary_key=True, serialize=False)),
-                ('body', models.TextField(help_text='The content of the tag.')),
-                ('url', models.URLField(help_text='The URL to this tag on GitHub.')),
-            ],
-        ),
-    ]
diff --git a/pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py b/pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py
deleted file mode 100644
index e59077f0..00000000
--- a/pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# Generated by Django 4.0.6 on 2022-08-13 23:48
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('content', '0001_initial'),
-    ]
-
-    operations = [
-        migrations.RemoveField(
-            model_name='tag',
-            name='url',
-        ),
-        migrations.AddField(
-            model_name='tag',
-            name='group',
-            field=models.CharField(help_text='The group the tag belongs to.', max_length=50, null=True),
-        ),
-    ]
diff --git a/pydis_site/apps/content/models/__init__.py b/pydis_site/apps/content/models/__init__.py
index 2718ce94..60007e27 100644
--- a/pydis_site/apps/content/models/__init__.py
+++ b/pydis_site/apps/content/models/__init__.py
@@ -1,3 +1,3 @@
-from .tag import Tag
+from .tag import Commit, Tag
 
-__all__ = ["Tag"]
+__all__ = ["Commit", "Tag"]
diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py
index 01264ff1..1c89fe1e 100644
--- a/pydis_site/apps/content/models/tag.py
+++ b/pydis_site/apps/content/models/tag.py
@@ -1,6 +1,36 @@
+import json
+
 from django.db import models
 
 
+class Commit(models.Model):
+    """A git commit."""
+
+    URL_BASE = "https://github.com/python-discord/bot/commit/"
+
+    sha = models.CharField(
+        help_text="The SHA hash of this commit.",
+        primary_key=True,
+        max_length=40,
+    )
+    message = models.TextField(help_text="The commit message.")
+    date = models.DateTimeField(help_text="The date and time the commit was created.")
+    author = models.TextField(help_text="The person(s) who created the commit.")
+
+    @property
+    def url(self) -> str:
+        """The URL to the commit on GitHub."""
+        return self.URL_BASE + self.sha
+
+    @property
+    def format_users(self) -> str:
+        """Return a nice representation of the user(s)' name and email."""
+        authors = []
+        for author in json.loads(self.author):
+            authors.append(f"{author['name']} <{author['email']}>")
+        return ", ".join(authors)
+
+
 class Tag(models.Model):
     """A tag from the python-discord server."""
 
@@ -10,6 +40,12 @@ class Tag(models.Model):
         help_text="The date and time this data was last fetched.",
         auto_now=True,
     )
+    last_commit = models.OneToOneField(
+        Commit,
+        help_text="The commit this file was last touched in.",
+        null=True,
+        on_delete=models.CASCADE,
+    )
     name = models.CharField(
         help_text="The tag's name.",
         primary_key=True,
-- 
cgit v1.2.3
From 04babac2f281487adcddbf1e92d9d028896e086e Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Tue, 16 Aug 2022 21:21:59 +0400
Subject: Add Tag Metadata
Uses the commit API to obtain tag metadata such as when it was last
edited, and by whom.
Signed-off-by: Hassan Abouelela 
---
 .../apps/content/migrations/0001_add_tags.py       |   5 +-
 pydis_site/apps/content/models/tag.py              |  14 ++-
 pydis_site/apps/content/utils.py                   | 124 ++++++++++++++++++---
 pydis_site/static/css/content/tag.css              |   6 +-
 pydis_site/templates/content/tag.html              |  22 +++-
 5 files changed, 146 insertions(+), 25 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/migrations/0001_add_tags.py b/pydis_site/apps/content/migrations/0001_add_tags.py
index 2e9d8c45..73525243 100644
--- a/pydis_site/apps/content/migrations/0001_add_tags.py
+++ b/pydis_site/apps/content/migrations/0001_add_tags.py
@@ -1,4 +1,4 @@
-# Generated by Django 4.0.6 on 2022-08-16 16:17
+# Generated by Django 4.0.6 on 2022-08-16 17:38
 
 import django.db.models.deletion
 from django.db import migrations, models
@@ -25,10 +25,11 @@ class Migration(migrations.Migration):
             name='Tag',
             fields=[
                 ('last_updated', models.DateTimeField(auto_now=True, help_text='The date and time this data was last fetched.')),
+                ('sha', models.CharField(help_text="The tag's hash, as calculated by GitHub.", max_length=40)),
                 ('name', models.CharField(help_text="The tag's name.", max_length=50, primary_key=True, serialize=False)),
                 ('group', models.CharField(help_text='The group the tag belongs to.', max_length=50, null=True)),
                 ('body', models.TextField(help_text='The content of the tag.')),
-                ('last_commit', models.OneToOneField(help_text='The commit this file was last touched in.', null=True, on_delete=django.db.models.deletion.CASCADE, to='content.commit')),
+                ('last_commit', models.ForeignKey(help_text='The commit this file was last touched in.', null=True, on_delete=django.db.models.deletion.CASCADE, to='content.commit')),
             ],
         ),
     ]
diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py
index 1c89fe1e..3c729768 100644
--- a/pydis_site/apps/content/models/tag.py
+++ b/pydis_site/apps/content/models/tag.py
@@ -1,3 +1,4 @@
+import collections.abc
 import json
 
 from django.db import models
@@ -22,13 +23,10 @@ class Commit(models.Model):
         """The URL to the commit on GitHub."""
         return self.URL_BASE + self.sha
 
-    @property
-    def format_users(self) -> str:
+    def format_users(self) -> collections.abc.Iterable[str]:
         """Return a nice representation of the user(s)' name and email."""
-        authors = []
         for author in json.loads(self.author):
-            authors.append(f"{author['name']} <{author['email']}>")
-        return ", ".join(authors)
+            yield f"{author['name']} <{author['email']}>"
 
 
 class Tag(models.Model):
@@ -40,7 +38,11 @@ class Tag(models.Model):
         help_text="The date and time this data was last fetched.",
         auto_now=True,
     )
-    last_commit = models.OneToOneField(
+    sha = models.CharField(
+        help_text="The tag's hash, as calculated by GitHub.",
+        max_length=40,
+    )
+    last_commit = models.ForeignKey(
         Commit,
         help_text="The commit this file was last touched in.",
         null=True,
diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py
index 11100ba5..7b078de6 100644
--- a/pydis_site/apps/content/utils.py
+++ b/pydis_site/apps/content/utils.py
@@ -1,5 +1,6 @@
 import datetime
 import functools
+import json
 import tarfile
 import tempfile
 import typing
@@ -15,11 +16,26 @@ from django.utils import timezone
 from markdown.extensions.toc import TocExtension
 
 from pydis_site import settings
-from .models import Tag
+from .models import Commit, Tag
 
 TAG_CACHE_TTL = datetime.timedelta(hours=1)
 
 
+def github_client(**kwargs) -> httpx.Client:
+    """Get a client to access the GitHub API with important settings pre-configured."""
+    client = httpx.Client(
+        base_url=settings.GITHUB_API,
+        follow_redirects=True,
+        timeout=settings.TIMEOUT_PERIOD,
+        **kwargs
+    )
+    if settings.GITHUB_TOKEN:  # pragma: no cover
+        if not client.headers.get("Authorization"):
+            client.headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"}
+
+    return client
+
+
 def get_category(path: Path) -> dict[str, str]:
     """Load category information by name from _info.yml."""
     if not path.is_dir():
@@ -60,19 +76,31 @@ def fetch_tags() -> list[Tag]:
     The entire repository is downloaded and extracted locally because
     getting file content would require one request per file, and can get rate-limited.
     """
-    if settings.GITHUB_TOKEN:  # pragma: no cover
-        headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"}
-    else:
-        headers = {}
+    client = github_client()
+
+    # Grab metadata
+    metadata = client.get("/repos/python-discord/bot/contents/bot/resources")
+    metadata.raise_for_status()
+
+    hashes = {}
+    for entry in metadata.json():
+        if entry["type"] == "dir":
+            # Tag group
+            files = client.get(entry["url"])
+            files.raise_for_status()
+            files = files.json()
+        else:
+            files = [entry]
 
-    tar_file = httpx.get(
-        f"{settings.GITHUB_API}/repos/python-discord/bot/tarball",
-        follow_redirects=True,
-        timeout=settings.TIMEOUT_PERIOD,
-        headers=headers,
-    )
+        for file in files:
+            hashes[file["name"]] = file["sha"]
+
+    # Download the files
+    tar_file = client.get("/repos/python-discord/bot/tarball")
     tar_file.raise_for_status()
 
+    client.close()
+
     tags = []
     with tempfile.TemporaryDirectory() as folder:
         with tarfile.open(fileobj=BytesIO(tar_file.content)) as repo:
@@ -83,20 +111,83 @@ def fetch_tags() -> list[Tag]:
             repo.extractall(folder, included)
 
         for tag_file in Path(folder).rglob("*.md"):
+            name = tag_file.name
             group = None
             if tag_file.parent.name != "tags":
                 # Tags in sub-folders are considered part of a group
                 group = tag_file.parent.name
 
             tags.append(Tag(
-                name=tag_file.name.removesuffix(".md"),
+                name=name.removesuffix(".md"),
+                sha=hashes[name],
                 group=group,
                 body=tag_file.read_text(encoding="utf-8"),
+                last_commit=None,
             ))
 
     return tags
 
 
+def set_tag_commit(tag: Tag) -> Tag:
+    """Fetch commit information from the API, and save it for the tag."""
+    path = "/bot/resources/tags"
+    if tag.group:
+        path += f"/{tag.group}"
+    path += f"/{tag.name}.md"
+
+    # Fetch and set the commit
+    with github_client() as client:
+        data = client.get("/repos/python-discord/bot/commits", params={"path": path})
+        data.raise_for_status()
+        data = data.json()[0]
+
+    commit = data["commit"]
+    author, committer = commit["author"], commit["committer"]
+
+    date = datetime.datetime.strptime(committer["date"], settings.GITHUB_TIMESTAMP_FORMAT)
+    date = date.replace(tzinfo=datetime.timezone.utc)
+
+    if author["email"] == committer["email"]:
+        commit_author = [author]
+    else:
+        commit_author = [author, committer]
+
+    commit_obj, _ = Commit.objects.get_or_create(
+        sha=data["sha"],
+        message=commit["message"],
+        date=date,
+        author=json.dumps(commit_author),
+    )
+    tag.last_commit = commit_obj
+    tag.save()
+
+    return tag
+
+
+def record_tags(tags: list[Tag]) -> None:
+    """Sync the database with an updated set of tags."""
+    # Remove entries which no longer exist
+    Tag.objects.exclude(name__in=[tag.name for tag in tags]).delete()
+
+    # Insert/update the tags
+    for tag in tags:
+        try:
+            old_tag = Tag.objects.get(name=tag.name)
+        except Tag.DoesNotExist:
+            # The tag is not in the database yet,
+            # pretend it's previous state is the current state
+            old_tag = tag
+
+        if old_tag.sha == tag.sha and old_tag.last_commit is not None:
+            # We still have an up-to-date commit entry
+            tag.last_commit = old_tag.last_commit
+
+        tag.save()
+
+    # Drop old, unused commits
+    Commit.objects.filter(tag__isnull=True).delete()
+
+
 def get_tags() -> list[Tag]:
     """Return a list of all tags visible to the application, from the cache or API."""
     if settings.STATIC_BUILD:  # pragma: no cover
@@ -113,9 +204,7 @@ def get_tags() -> list[Tag]:
             tags = get_tags_static()
         else:
             tags = fetch_tags()
-            Tag.objects.exclude(name__in=[tag.name for tag in tags]).delete()
-            for tag in tags:
-                tag.save()
+            record_tags(tags)
 
         return tags
     else:
@@ -127,6 +216,9 @@ def get_tag(path: str) -> typing.Union[Tag, list[Tag]]:
     """
     Return a tag based on the search location.
 
+    If certain tag data is out of sync (for instance a commit date is missing),
+    an extra request will be made to sync the information.
+
     The tag name and group must match. If only one argument is provided in the path,
     it's assumed to either be a group name, or a no-group tag name.
 
@@ -142,6 +234,8 @@ def get_tag(path: str) -> typing.Union[Tag, list[Tag]]:
     matches = []
     for tag in get_tags():
         if tag.name == name and tag.group == group:
+            if tag.last_commit is None:
+                set_tag_commit(tag)
             return tag
         elif tag.group == name and group is None:
             matches.append(tag)
diff --git a/pydis_site/static/css/content/tag.css b/pydis_site/static/css/content/tag.css
index 32a605a8..79795f9e 100644
--- a/pydis_site/static/css/content/tag.css
+++ b/pydis_site/static/css/content/tag.css
@@ -5,5 +5,9 @@
 }
 
 .content a *:hover {
-    color: black;
+    color: dimgray;
+}
+
+span.update-time {
+    text-decoration: black underline dotted;
 }
diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html
index 9bd65744..513009da 100644
--- a/pydis_site/templates/content/tag.html
+++ b/pydis_site/templates/content/tag.html
@@ -9,10 +9,30 @@
 {% endblock %}
 
 {% block title_element %}
-    
+    
+
+    
 {% endblock %}
-- 
cgit v1.2.3
From 7c240c68e24c0f3bf041522ce21de271cb92c6f3 Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Tue, 16 Aug 2022 22:08:39 +0400
Subject: Better Split Up Tag Commit Messages
Signed-off-by: Hassan Abouelela 
---
 pydis_site/apps/content/models/tag.py | 5 +++++
 pydis_site/templates/content/tag.html | 4 +++-
 2 files changed, 8 insertions(+), 1 deletion(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py
index 3c729768..c504ce21 100644
--- a/pydis_site/apps/content/models/tag.py
+++ b/pydis_site/apps/content/models/tag.py
@@ -23,6 +23,11 @@ class Commit(models.Model):
         """The URL to the commit on GitHub."""
         return self.URL_BASE + self.sha
 
+    def lines(self) -> collections.abc.Iterable[str]:
+        """Return each line in the commit message."""
+        for line in self.message.split("\n"):
+            yield line
+
     def format_users(self) -> collections.abc.Iterable[str]:
         """Return a nice representation of the user(s)' name and email."""
         for author in json.loads(self.author):
diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html
index 513009da..655dd786 100644
--- a/pydis_site/templates/content/tag.html
+++ b/pydis_site/templates/content/tag.html
@@ -31,7 +31,9 @@
                     {{ user }}
                 {% endfor %}
                 
-                {{ tag.last_commit.message }}
+                {% for line in tag.last_commit.lines %}
+                    {{ line }}
+                {% endfor %}
              
          
     
 
-- 
cgit v1.2.3
From 89b0853245fdf5ba7f1f386d7ea7ab1548b538da Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Tue, 16 Aug 2022 22:09:14 +0400
Subject: Fix Tag Metadata For Static Builds
Signed-off-by: Hassan Abouelela 
---
 pydis_site/apps/content/utils.py | 15 ++++++++++++---
 1 file changed, 12 insertions(+), 3 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py
index 7b078de6..e4a24a73 100644
--- a/pydis_site/apps/content/utils.py
+++ b/pydis_site/apps/content/utils.py
@@ -128,8 +128,19 @@ def fetch_tags() -> list[Tag]:
     return tags
 
 
-def set_tag_commit(tag: Tag) -> Tag:
+def set_tag_commit(tag: Tag) -> None:
     """Fetch commit information from the API, and save it for the tag."""
+    if settings.STATIC_BUILD:
+        # Static builds request every page during build, which can ratelimit it.
+        # Instead, we return some fake data.
+        tag.last_commit = Commit(
+            sha="68da80efc00d9932a209d5cccd8d344cec0f09ea",
+            message="Initial Commit\n\nTHIS IS FAKE DEMO DATA",
+            date=datetime.datetime(2018, 2, 3, 12, 20, 26, tzinfo=datetime.timezone.utc),
+            author=json.dumps([{"name": "Joseph", "email": "joseph@josephbanks.me"}]),
+        )
+        return
+
     path = "/bot/resources/tags"
     if tag.group:
         path += f"/{tag.group}"
@@ -161,8 +172,6 @@ def set_tag_commit(tag: Tag) -> Tag:
     tag.last_commit = commit_obj
     tag.save()
 
-    return tag
-
 
 def record_tags(tags: list[Tag]) -> None:
     """Sync the database with an updated set of tags."""
-- 
cgit v1.2.3
From 92a42694b6ad1a29e5a21e0b3e57639528837113 Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Tue, 16 Aug 2022 23:45:25 +0400
Subject: Fix Tests For Tag Metadata
Signed-off-by: Hassan Abouelela 
---
 pydis_site/apps/api/tests/test_github_utils.py |   7 +-
 pydis_site/apps/content/tests/test_utils.py    | 132 +++++++++++++++++++++++--
 pydis_site/apps/content/tests/test_views.py    |  36 ++++---
 pydis_site/apps/content/utils.py               |   2 +-
 4 files changed, 148 insertions(+), 29 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py
index f642f689..6e25bc80 100644
--- a/pydis_site/apps/api/tests/test_github_utils.py
+++ b/pydis_site/apps/api/tests/test_github_utils.py
@@ -11,6 +11,7 @@ import rest_framework.response
 import rest_framework.test
 from django.urls import reverse
 
+from pydis_site import settings
 from .. import github_utils
 
 
@@ -49,7 +50,7 @@ class CheckRunTests(unittest.TestCase):
         "head_sha": "sha",
         "status": "completed",
         "conclusion": "success",
-        "created_at": datetime.datetime.utcnow().strftime(github_utils.ISO_FORMAT_STRING),
+        "created_at": datetime.datetime.utcnow().strftime(settings.GITHUB_TIMESTAMP_FORMAT),
         "artifacts_url": "url",
     }
 
@@ -74,7 +75,7 @@ class CheckRunTests(unittest.TestCase):
         # to guarantee the right conclusion
         kwargs["created_at"] = (
             datetime.datetime.utcnow() - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10)
-        ).strftime(github_utils.ISO_FORMAT_STRING)
+        ).strftime(settings.GITHUB_TIMESTAMP_FORMAT)
 
         with self.assertRaises(github_utils.RunTimeoutError):
             github_utils.check_run_status(github_utils.WorkflowRun(**kwargs))
@@ -178,7 +179,7 @@ class ArtifactFetcherTests(unittest.TestCase):
                 run = github_utils.WorkflowRun(
                     name="action_name",
                     head_sha="action_sha",
-                    created_at=datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING),
+                    created_at=datetime.datetime.now().strftime(settings.GITHUB_TIMESTAMP_FORMAT),
                     status="completed",
                     conclusion="success",
                     artifacts_url="artifacts_url"
diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py
index 556f633c..2ef033e4 100644
--- a/pydis_site/apps/content/tests/test_utils.py
+++ b/pydis_site/apps/content/tests/test_utils.py
@@ -1,3 +1,5 @@
+import datetime
+import json
 import tarfile
 import tempfile
 import textwrap
@@ -15,6 +17,18 @@ from pydis_site.apps.content.tests.helpers import (
     BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA
 )
 
+_time = datetime.datetime(2022, 10, 10, 10, 10, 10, tzinfo=datetime.timezone.utc)
+_time_str = _time.strftime(settings.GITHUB_TIMESTAMP_FORMAT)
+TEST_COMMIT_KWARGS = {
+    "sha": "123",
+    "message": "Hello world\n\nThis is a commit message",
+    "date": _time,
+    "author": json.dumps([
+        {"name": "Author 1", "email": "mail1@example.com", "date": _time_str},
+        {"name": "Author 2", "email": "mail2@example.com", "date": _time_str},
+    ]),
+}
+
 
 class GetCategoryTests(MockPagesTestCase):
     """Tests for the get_category function."""
@@ -109,6 +123,10 @@ class GetPageTests(MockPagesTestCase):
 class TagUtilsTests(TestCase):
     """Tests for the tag-related utilities."""
 
+    def setUp(self) -> None:
+        super().setUp()
+        self.commit = models.Commit.objects.create(**TEST_COMMIT_KWARGS)
+
     @mock.patch.object(utils, "fetch_tags")
     def test_static_fetch(self, fetch_mock: mock.Mock):
         """Test that the static fetch function is only called at most once during static builds."""
@@ -121,9 +139,27 @@ class TagUtilsTests(TestCase):
         self.assertEqual(tags, result)
         self.assertEqual(tags, second_result)
 
-    @mock.patch("httpx.get")
+    @mock.patch("httpx.Client.get")
     def test_mocked_fetch(self, get_mock: mock.Mock):
         """Test that proper data is returned from fetch, but with a mocked API response."""
+        fake_request = httpx.Request("GET", "https://google.com")
+
+        # Metadata requests
+        returns = [httpx.Response(
+            request=fake_request,
+            status_code=200,
+            json=[
+                {"type": "file", "name": "first_tag.md", "sha": "123"},
+                {"type": "file", "name": "second_tag.md", "sha": "456"},
+                {"type": "dir", "name": "some_group", "sha": "789", "url": "/some_group"},
+            ]
+        ), httpx.Response(
+            request=fake_request,
+            status_code=200,
+            json=[{"type": "file", "name": "grouped_tag.md", "sha": "789123"}]
+        )]
+
+        # Main content request
         bodies = (
             "This is the first tag!",
             textwrap.dedent("""
@@ -156,33 +192,36 @@ class TagUtilsTests(TestCase):
 
                 body = (tar_folder / "temp.tar").read_bytes()
 
-        get_mock.return_value = httpx.Response(
+        returns.append(httpx.Response(
             status_code=200,
             content=body,
-            request=httpx.Request("GET", "https://google.com"),
-        )
+            request=fake_request,
+        ))
 
+        get_mock.side_effect = returns
         result = utils.fetch_tags()
 
         def sort(_tag: models.Tag) -> str:
             return _tag.name
 
         self.assertEqual(sorted([
-            models.Tag(name="first_tag", body=bodies[0]),
-            models.Tag(name="second_tag", body=bodies[1]),
-            models.Tag(name="grouped_tag", body=bodies[2], group=group_folder.name),
+            models.Tag(name="first_tag", body=bodies[0], sha="123"),
+            models.Tag(name="second_tag", body=bodies[1], sha="245"),
+            models.Tag(name="grouped_tag", body=bodies[2], group=group_folder.name, sha="789123"),
         ], key=sort), sorted(result, key=sort))
 
     def test_get_real_tag(self):
         """Test that a single tag is returned if it exists."""
-        tag = models.Tag.objects.create(name="real-tag")
+        tag = models.Tag.objects.create(name="real-tag", last_commit=self.commit)
         result = utils.get_tag("real-tag")
 
         self.assertEqual(tag, result)
 
     def test_get_grouped_tag(self):
         """Test fetching a tag from a group."""
-        tag = models.Tag.objects.create(name="real-tag", group="real-group")
+        tag = models.Tag.objects.create(
+            name="real-tag", group="real-group", last_commit=self.commit
+        )
         result = utils.get_tag("real-group/real-tag")
 
         self.assertEqual(tag, result)
@@ -269,3 +308,78 @@ class TagUtilsTests(TestCase):
             tag = models.Tag(**options)
             with self.subTest(tag=tag):
                 self.assertEqual(url, tag.url)
+
+    @mock.patch("httpx.Client.get")
+    def test_get_tag_commit(self, get_mock: mock.Mock):
+        """Test the get commit function with a normal tag."""
+        tag = models.Tag.objects.create(name="example")
+
+        authors = json.loads(self.commit.author)
+
+        get_mock.return_value = httpx.Response(
+            request=httpx.Request("GET", "https://google.com"),
+            status_code=200,
+            json=[{
+                "sha": self.commit.sha,
+                "commit": {
+                    "message": self.commit.message,
+                    "author": authors[0],
+                    "committer": authors[1],
+                }
+            }]
+        )
+
+        result = utils.get_tag(tag.name)
+        self.assertEqual(tag, result)
+
+        get_mock.assert_called_once()
+        call_params = get_mock.call_args[1]["params"]
+
+        self.assertEqual({"path": "/bot/resources/tags/example.md"}, call_params)
+        self.assertEqual(self.commit, models.Tag.objects.get(name=tag.name).last_commit)
+
+    @mock.patch("httpx.Client.get")
+    def test_get_group_tag_commit(self, get_mock: mock.Mock):
+        """Test the get commit function with a group tag."""
+        tag = models.Tag.objects.create(name="example", group="group-name")
+
+        authors = json.loads(self.commit.author)
+        authors.pop()
+        self.commit.author = json.dumps(authors)
+        self.commit.save()
+
+        get_mock.return_value = httpx.Response(
+            request=httpx.Request("GET", "https://google.com"),
+            status_code=200,
+            json=[{
+                "sha": self.commit.sha,
+                "commit": {
+                    "message": self.commit.message,
+                    "author": authors[0],
+                    "committer": authors[0],
+                }
+            }]
+        )
+
+        utils.set_tag_commit(tag)
+
+        get_mock.assert_called_once()
+        call_params = get_mock.call_args[1]["params"]
+
+        self.assertEqual({"path": "/bot/resources/tags/group-name/example.md"}, call_params)
+        self.assertEqual(self.commit, models.Tag.objects.get(name=tag.name).last_commit)
+
+    @mock.patch.object(utils, "set_tag_commit")
+    def test_exiting_commit(self, set_commit_mock: mock.Mock):
+        """Test that a commit is saved when the data has not changed."""
+        tag = models.Tag.objects.create(name="tag-name", body="old body", last_commit=self.commit)
+
+        # This is only applied to the object, not to the database
+        tag.last_commit = None
+
+        utils.record_tags([tag])
+        self.assertEqual(self.commit, tag.last_commit)
+
+        result = utils.get_tag("tag-name")
+        self.assertEqual(tag, result)
+        set_commit_mock.assert_not_called()
diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py
index c5c25be4..658ac2cc 100644
--- a/pydis_site/apps/content/tests/test_views.py
+++ b/pydis_site/apps/content/tests/test_views.py
@@ -8,10 +8,11 @@ from django.http import Http404
 from django.test import RequestFactory, SimpleTestCase, override_settings
 from django.urls import reverse
 
-from pydis_site.apps.content.models import Tag
+from pydis_site.apps.content.models import Commit, Tag
 from pydis_site.apps.content.tests.helpers import (
     BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA
 )
+from pydis_site.apps.content.tests.test_utils import TEST_COMMIT_KWARGS
 from pydis_site.apps.content.views import PageOrCategoryView
 
 
@@ -193,11 +194,12 @@ class TagViewTests(django.test.TestCase):
     def setUp(self):
         """Set test helpers, then set up fake filesystem."""
         super().setUp()
+        self.commit = Commit.objects.create(**TEST_COMMIT_KWARGS)
 
     def test_routing(self):
         """Test that the correct template is returned for each route."""
-        Tag.objects.create(name="example")
-        Tag.objects.create(name="grouped-tag", group="group-name")
+        Tag.objects.create(name="example", last_commit=self.commit)
+        Tag.objects.create(name="grouped-tag", group="group-name", last_commit=self.commit)
 
         cases = [
             ("/pages/tags/example/", "content/tag.html"),
@@ -213,7 +215,7 @@ class TagViewTests(django.test.TestCase):
 
     def test_valid_tag_returns_200(self):
         """Test that a page is returned for a valid tag."""
-        Tag.objects.create(name="example", body="This is the tag body.")
+        Tag.objects.create(name="example", body="This is the tag body.", last_commit=self.commit)
         response = self.client.get("/pages/tags/example/")
         self.assertEqual(200, response.status_code)
         self.assertIn("This is the tag body", response.content.decode("utf-8"))
@@ -233,7 +235,7 @@ class TagViewTests(django.test.TestCase):
         Tag content here.
         """)
 
-        tag = Tag.objects.create(name="example", body=body)
+        tag = Tag.objects.create(name="example", body=body, last_commit=self.commit)
         response = self.client.get("/pages/tags/example/")
         expected = {
             "page_title": "example",
@@ -256,7 +258,9 @@ class TagViewTests(django.test.TestCase):
         The only difference between this and a regular tag are the breadcrumbs,
         so only those are checked.
         """
-        Tag.objects.create(name="example", body="Body text", group="group-name")
+        Tag.objects.create(
+            name="example", body="Body text", group="group-name", last_commit=self.commit
+        )
         response = self.client.get("/pages/tags/group-name/example/")
         self.assertListEqual([
             {"name": "Pages", "path": "."},
@@ -266,9 +270,9 @@ class TagViewTests(django.test.TestCase):
 
     def test_group_page(self):
         """Test rendering of a group's root page."""
-        Tag.objects.create(name="tag-1", body="Body 1", group="group-name")
-        Tag.objects.create(name="tag-2", body="Body 2", group="group-name")
-        Tag.objects.create(name="not-included")
+        Tag.objects.create(name="tag-1", body="Body 1", group="group-name", last_commit=self.commit)
+        Tag.objects.create(name="tag-2", body="Body 2", group="group-name", last_commit=self.commit)
+        Tag.objects.create(name="not-included", last_commit=self.commit)
 
         response = self.client.get("/pages/tags/group-name/")
         content = response.content.decode("utf-8")
@@ -298,7 +302,7 @@ class TagViewTests(django.test.TestCase):
         **This text is in bold**
         """)
 
-        Tag.objects.create(name="example", body=body)
+        Tag.objects.create(name="example", body=body, last_commit=self.commit)
         response = self.client.get("/pages/tags/example/")
         content = response.content.decode("utf-8")
 
@@ -317,7 +321,7 @@ class TagViewTests(django.test.TestCase):
         Tag body.
         """)
 
-        Tag.objects.create(name="example", body=body)
+        Tag.objects.create(name="example", body=body, last_commit=self.commit)
         response = self.client.get("/pages/tags/example/")
         content = response.content.decode("utf-8")
 
@@ -333,7 +337,7 @@ class TagViewTests(django.test.TestCase):
         ---
         """)
 
-        Tag.objects.create(name="example", body=body)
+        Tag.objects.create(name="example", body=body, last_commit=self.commit)
         response = self.client.get("/pages/tags/example/")
         self.assertEqual(
             "Embed title",
@@ -345,7 +349,7 @@ class TagViewTests(django.test.TestCase):
         """Test hyperlinking of tags works as intended."""
         filler_before, filler_after = "empty filler text\n\n", "more\nfiller"
         body = filler_before + "`!tags return`" + filler_after
-        Tag.objects.create(name="example", body=body)
+        Tag.objects.create(name="example", body=body, last_commit=self.commit)
 
         other_url = reverse("content:tag", kwargs={"location": "return"})
         response = self.client.get("/pages/tags/example/")
@@ -356,9 +360,9 @@ class TagViewTests(django.test.TestCase):
 
     def test_tag_root_page(self):
         """Test the root tag page which lists all tags."""
-        Tag.objects.create(name="tag-1")
-        Tag.objects.create(name="tag-2")
-        Tag.objects.create(name="tag-3")
+        Tag.objects.create(name="tag-1", last_commit=self.commit)
+        Tag.objects.create(name="tag-2", last_commit=self.commit)
+        Tag.objects.create(name="tag-3", last_commit=self.commit)
 
         response = self.client.get("/pages/tags/")
         content = response.content.decode("utf-8")
diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py
index e4a24a73..63f1c41c 100644
--- a/pydis_site/apps/content/utils.py
+++ b/pydis_site/apps/content/utils.py
@@ -130,7 +130,7 @@ def fetch_tags() -> list[Tag]:
 
 def set_tag_commit(tag: Tag) -> None:
     """Fetch commit information from the API, and save it for the tag."""
-    if settings.STATIC_BUILD:
+    if settings.STATIC_BUILD:  # pragma: no cover
         # Static builds request every page during build, which can ratelimit it.
         # Instead, we return some fake data.
         tag.last_commit = Commit(
-- 
cgit v1.2.3
From c0823236d20e801550fccdbb021d8aabb56d59c0 Mon Sep 17 00:00:00 2001
From: Amrou Bellalouna 
Date: Thu, 18 Aug 2022 16:58:08 +0100
Subject: add collection of keywords per rule
In reference to issue #2108, this commit aims to add an initial set of keywords per rule.
These keywords will be later in the "rule" bot command in order to make rule identification easier
---
 pydis_site/apps/api/views.py | 27 ++++++++++++++++++---------
 1 file changed, 18 insertions(+), 9 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py
index ad2d948e..f96d6a8d 100644
--- a/pydis_site/apps/api/views.py
+++ b/pydis_site/apps/api/views.py
@@ -124,35 +124,44 @@ class RulesView(APIView):
 
         return Response([
             (
-                f"Follow the {pydis_coc}."
+                f"Follow the {pydis_coc}.",
+                {"coc", "conduct", "code"}
             ),
             (
-                f"Follow the {discord_community_guidelines} and {discord_tos}."
+                f"Follow the {discord_community_guidelines} and {discord_tos}.",
+                {"guidelines", "discord_tos"}
             ),
             (
-                "Respect staff members and listen to their instructions."
+                "Respect staff members and listen to their instructions.",
+                {"staff", "instructions"}
             ),
             (
                 "Use English to the best of your ability. "
-                "Be polite if someone speaks English imperfectly."
+                "Be polite if someone speaks English imperfectly.",
+                {"english", "language"}
             ),
             (
                 "Do not provide or request help on projects that may break laws, "
-                "breach terms of services, or are malicious or inappropriate."
+                "breach terms of services, or are malicious or inappropriate.",
+                {"infraction", "tos", "breach", "malicious", "inappropriate"}
             ),
             (
-                "Do not post unapproved advertising."
+                "Do not post unapproved advertising.",
+                {"ads", "advertising"}
             ),
             (
                 "Keep discussions relevant to the channel topic. "
-                "Each channel's description tells you the topic."
+                "Each channel's description tells you the topic.",
+                {"off-topic", "topic", "relevance"}
             ),
             (
                 "Do not help with ongoing exams. When helping with homework, "
-                "help people learn how to do the assignment without doing it for them."
+                "help people learn how to do the assignment without doing it for them.",
+                {"exams", "assignment", "assignments", "homework"}
             ),
             (
-                "Do not offer or ask for paid work of any kind."
+                "Do not offer or ask for paid work of any kind.",
+                {"work", "money"}
             ),
         ])
 
-- 
cgit v1.2.3
From c0384c626121684ad4e354aeb817fdbd2741fc4f Mon Sep 17 00:00:00 2001
From: Hassan Abouelela 
Date: Tue, 23 Aug 2022 01:09:01 +0400
Subject: Improve Tag Commit Naming
Signed-off-by: Hassan Abouelela 
---
 pydis_site/apps/content/models/tag.py | 6 +++---
 pydis_site/apps/content/urls.py       | 2 +-
 pydis_site/templates/content/tag.html | 2 +-
 3 files changed, 5 insertions(+), 5 deletions(-)
(limited to 'pydis_site')
diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py
index c504ce21..73d6cb79 100644
--- a/pydis_site/apps/content/models/tag.py
+++ b/pydis_site/apps/content/models/tag.py
@@ -5,7 +5,7 @@ from django.db import models
 
 
 class Commit(models.Model):
-    """A git commit."""
+    """A git commit from the Python Discord Bot project."""
 
     URL_BASE = "https://github.com/python-discord/bot/commit/"
 
@@ -28,8 +28,8 @@ class Commit(models.Model):
         for line in self.message.split("\n"):
             yield line
 
-    def format_users(self) -> collections.abc.Iterable[str]:
-        """Return a nice representation of the user(s)' name and email."""
+    def format_authors(self) -> collections.abc.Iterable[str]:
+        """Return a nice representation of the author(s)' name and email."""
         for author in json.loads(self.author):
             yield f"{author['name']} <{author['email']}>"
 
diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py
index 03c0015a..163d05bc 100644
--- a/pydis_site/apps/content/urls.py
+++ b/pydis_site/apps/content/urls.py
@@ -63,5 +63,5 @@ urlpatterns = [
         views.PageOrCategoryView.as_view(),
         name='page_category',
         distill_func=get_all_pages
-    )
+    ),
 ]
diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html
index 655dd786..fa9e44f5 100644
--- a/pydis_site/templates/content/tag.html
+++ b/pydis_site/templates/content/tag.html
@@ -27,7 +27,7 @@