aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--pydis_site/apps/api/admin.py12
-rw-r--r--pydis_site/apps/api/migrations/0085_new_filter_schema.py160
-rw-r--r--pydis_site/apps/api/migrations/0086_unique_constraint_filters.py37
-rw-r--r--pydis_site/apps/api/models/__init__.py2
-rw-r--r--pydis_site/apps/api/models/bot/__init__.py2
-rw-r--r--pydis_site/apps/api/models/bot/filter_list.py42
-rw-r--r--pydis_site/apps/api/models/bot/filters.py205
-rw-r--r--pydis_site/apps/api/serializers.py209
-rw-r--r--pydis_site/apps/api/tests/test_filterlists.py122
-rw-r--r--pydis_site/apps/api/tests/test_filters.py298
-rw-r--r--pydis_site/apps/api/tests/test_models.py14
-rw-r--r--pydis_site/apps/api/urls.py9
-rw-r--r--pydis_site/apps/api/viewsets/__init__.py2
-rw-r--r--pydis_site/apps/api/viewsets/bot/__init__.py5
-rw-r--r--pydis_site/apps/api/viewsets/bot/filter_list.py98
-rw-r--r--pydis_site/apps/api/viewsets/bot/filters.py303
16 files changed, 1245 insertions, 275 deletions
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
[email protected](FilterList)
+class FilterListAdmin(admin.ModelAdmin):
+ """Admin formatting for the FilterList model."""
+
+
+class FilterAdmin(admin.ModelAdmin):
+ """Admin formatting for the Filter model."""
+
+
@admin.register(MessageDeletionContext)
class MessageDeletionContextAdmin(admin.ModelAdmin):
"""Admin formatting for the MessageDeletionContext model."""
diff --git a/pydis_site/apps/api/migrations/0085_new_filter_schema.py b/pydis_site/apps/api/migrations/0085_new_filter_schema.py
new file mode 100644
index 00000000..d16c26ac
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0085_new_filter_schema.py
@@ -0,0 +1,160 @@
+# 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', True), ('GUILD_INVITE', False), ('FILE_FORMAT', True), ('DOMAIN_NAME', False), ('FILTER_TOKEN', False), ('REDIRECT', False))
+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, allowed=type_)
+ if name == "DOMAIN_NAME":
+ 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"
+ else:
+ dm_content = ""
+
+ list_ = filter_list.objects.create(
+ name=change_map[name],
+ list_type=int(type_),
+ guild_pings=(["Moderators"] if name != "FILE_FORMAT" else []),
+ filter_dm=True,
+ dm_pings=[],
+ delete_messages=(True if name != "FILTER_TOKEN" else False),
+ bypass_roles=["Helpers"],
+ enabled=True,
+ dm_content=dm_content,
+ dm_embed="" if name != "FILE_FORMAT" else "*Defined at runtime.*",
+ 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=[],
+ enabled_categories=[],
+ 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,
+ guild_pings=None,
+ filter_dm=None,
+ dm_pings=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,
+ enabled_categories=None,
+ send_alert=None,
+ )
+ new_object.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0084_infraction_last_applied'),
+ ]
+
+ 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', models.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, 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, 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, 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'), ('VOICE_MUTE', 'Voice Mute')], help_text='The infraction to apply to this user.', max_length=10, 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)),
+ ('enabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="The only categories in which to run the filter.", 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')),
+ ('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)),
+ ('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)),
+ ('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'), ('VOICE_MUTE', 'Voice Mute')], help_text='The infraction to apply to this user.', max_length=10, 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.", 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)),
+ ('enabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="The only categories in which to run the filter.", 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'
+ )
+ ]
diff --git a/pydis_site/apps/api/migrations/0086_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0086_unique_constraint_filters.py
new file mode 100644
index 00000000..8072ed2e
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0086_unique_constraint_filters.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.1.14 on 2022-03-22 16:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0085_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',
+ 'enabled_categories',
+ 'disabled_categories'
+ ), name='unique_filters'),
+ ),
+ ]
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index a197e988..580c95a0 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -1,5 +1,7 @@
# flake8: noqa
from .bot import (
+ FilterList,
+ Filter,
BotSetting,
BumpedThread,
DocumentationLink,
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index 013bb85e..6f09473d 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -1,9 +1,9 @@
# flake8: noqa
+from .filters import FilterList, Filter
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
from .infraction import Infraction
from .message import Message
from .aoc_completionist_block import AocCompletionistBlock
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..95a10e42
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -0,0 +1,205 @@
+from django.contrib.postgres.fields import ArrayField
+from django.db import models
+from django.db.models import UniqueConstraint
+
+# Must be imported that way to avoid circular imports
+from .infraction import Infraction
+
+
+class FilterListType(models.IntegerChoices):
+ """Choice between allow or deny for a list type."""
+
+ ALLOW = 1
+ DENY = 0
+
+
+class FilterSettingsMixin(models.Model):
+ """Mixin for common settings of a filters and filter lists."""
+
+ dm_content = models.CharField(
+ max_length=1000,
+ 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=[(choices[0].upper(), choices[1]) for choices in Infraction.TYPE_CHOICES],
+ max_length=10,
+ 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.",
+ null=True
+ )
+ infraction_duration = models.DurationField(
+ null=True,
+ help_text="The duration of the infraction. Null if permanent."
+ )
+
+ class Meta:
+ """Metaclass for settings mixin."""
+
+ abstract = True
+
+
+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"
+ )
+ guild_pings = ArrayField(
+ 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=100),
+ 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.CharField(max_length=100),
+ 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
+ )
+ send_alert = models.BooleanField(
+ help_text="Whether an alert should be sent.",
+ )
+ # Where a filter should apply.
+ 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."
+ )
+ enabled_categories = ArrayField(
+ models.CharField(max_length=100),
+ help_text="The only categories in which to 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."""
+
+ 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 FilterBase(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.", null=True
+ )
+ additional_field = models.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=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=100),
+ 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.CharField(max_length=100),
+ 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
+ )
+ send_alert = models.BooleanField(
+ help_text="Whether an alert should be sent.",
+ null=True
+ )
+
+ # Check FilterList model for information about these properties.
+ 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
+ )
+ enabled_categories = ArrayField(
+ models.CharField(max_length=100),
+ help_text="The only categories in which to 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}"
+
+ 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"),
+ )
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 9228c1f4..7c1c107a 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -19,8 +19,9 @@ from .models import (
BumpedThread,
DeletedMessage,
DocumentationLink,
- FilterList,
Infraction,
+ FilterList,
+ Filter,
MessageDeletionContext,
Nomination,
NominationEntry,
@@ -141,30 +142,216 @@ 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 = (
+ 'guild_pings',
+ 'filter_dm',
+ 'dm_pings',
+ 'delete_messages',
+ 'send_alert',
+ 'bypass_roles',
+ 'enabled',
+ 'enabled_channels',
+ 'disabled_channels',
+ 'enabled_categories',
+ '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 = (
+ "bypass_roles",
+ "filter_dm",
+ "enabled",
+ "delete_messages",
+ "send_alert"
+)
+INFRACTION_AND_NOTIFICATION_FIELDS = (
+ "infraction_type",
+ "infraction_reason",
+ "infraction_duration",
+ "dm_content",
+ "dm_embed"
+)
+CHANNEL_SCOPE_FIELDS = (
+ "disabled_channels",
+ "disabled_categories",
+ "enabled_channels",
+ "enabled_categories"
+)
+MENTIONS_FIELDS = ("guild_pings", "dm_pings")
+
+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: 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'):
+ raise ValidationError("Infraction type is required with infraction duration or reason")
+
+ if (
+ data.get('disabled_channels') is not None
+ and data.get('enabled_channels') is not None
+ ):
+ channels_collection = data['disabled_channels'] + data['enabled_channels']
+ if len(channels_collection) != len(set(channels_collection)):
+ raise ValidationError("Enabled and Disabled channels lists contain duplicates.")
+
+ if data.get('disabled_categories') is not None:
+ categories_collection = data['disabled_categories'] + data['enabled_categories']
+ if len(categories_collection) != len(set(categories_collection)):
+ raise ValidationError("Enabled and Disabled categories lists contain duplicates.")
+
+ return data
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ 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
+ } | {
+ 'infraction_reason': {'allow_blank': 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.
+
+ 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":
+ {name: getattr(instance, name) for name in BASE_SETTINGS_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}
+ } | {
+ "mentions":
+ {
+ schema_field_name: getattr(instance, schema_field_name)
+ for schema_field_name in MENTIONS_FIELDS
+ }
+ }
+ }
+ schema_base = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS} | \
+ {"filter_list": instance.filter_list.id}
+
+ return schema_base | schema_settings
+
+
class FilterListSerializer(ModelSerializer):
"""A class providing (de-)serialization of `FilterList` instances."""
+ filters = FilterSerializer(many=True, read_only=True)
+
+ 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'):
+ raise ValidationError("Infraction type is required with infraction duration or reason")
+
+ if (
+ data.get('disabled_channels') is not None
+ and data.get('enabled_channels') is not None
+ ):
+ channels_collection = data['disabled_channels'] + data['enabled_channels']
+ if len(channels_collection) != len(set(channels_collection)):
+ raise ValidationError("Enabled and Disabled channels lists contain duplicates.")
+
+ if data.get('disabled_categories') is not None:
+ categories_collection = data['disabled_categories']
+ if len(categories_collection) != len(set(categories_collection)):
+ raise ValidationError("Disabled categories lists contain duplicates.")
+
+ return data
+
class Meta:
"""Metadata defined for the Django REST Framework."""
model = FilterList
- fields = ('id', 'created_at', 'updated_at', 'type', 'allowed', 'content', 'comment')
-
- # 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.
+ 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},
+ '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
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."
)
),
]
+ def to_representation(self, instance: FilterList) -> dict:
+ """
+ Provides a custom JSON representation to the FilterList Serializers.
+
+ 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 = [
+ 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_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}} | {
+ "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}
+
class InfractionSerializer(ModelSerializer):
"""A class providing (de-)serialization of `Infraction` instances."""
@@ -203,7 +390,7 @@ class InfractionSerializer(ModelSerializer):
if hidden and infr_type in ('superstar', 'warning', 'voice_ban', 'voice_mute'):
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
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 9959617e..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.assertEqual(api_type[0], model_type[0])
- self.assertEqual(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..5f40c6f9
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_filters.py
@@ -0,0 +1,298 @@
+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: ("filter_list",),
+ 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(
+ dm_content=None,
+ infraction_type=None,
+ infraction_reason="",
+ infraction_duration=None
+ ),
+ default_range=ChannelRange(
+ disallowed_channels=[],
+ disallowed_categories=[],
+ allowed_channels=[],
+ allowed_categories=[],
+ 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(
+ 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_action": TestSequence(
+ FilterAction,
+ "filteraction",
+ {
+ "dm_content": "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_categories": [1121],
+ "default": True
+ }
+ ),
+ "filter": TestSequence(
+ Filter,
+ "filter",
+ {
+ "content": "bad word",
+ "description": "This is a really bad word.",
+ "additional_field": 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(
+ 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)
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index c07d59cd..b9b14a84 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -6,6 +6,9 @@ from django.test import SimpleTestCase, TestCase
from pydis_site.apps.api.models import (
DeletedMessage,
DocumentationLink,
+ Filter,
+ FilterList,
+ FilterSettings,
Infraction,
MessageDeletionContext,
Nomination,
@@ -104,6 +107,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,
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index 2757f176..f872ba92 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -10,6 +10,7 @@ from .viewsets import (
DeletedMessageViewSet,
DocumentationLinkViewSet,
FilterListViewSet,
+ FilterViewSet,
InfractionViewSet,
NominationViewSet,
OffTopicChannelNameViewSet,
@@ -22,6 +23,10 @@ from .viewsets import (
# https://www.django-rest-framework.org/api-guide/routers/#defaultrouter
bot_router = DefaultRouter(trailing_slash=False)
bot_router.register(
+ 'filter/filter_lists',
+ FilterListViewSet
+)
+bot_router.register(
"aoc-account-links",
AocAccountLinkViewSet
)
@@ -30,6 +35,10 @@ bot_router.register(
AocCompletionistBlockViewSet
)
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 ec52416a..1dae9be1 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -6,6 +6,8 @@ from .bot import (
DocumentationLinkViewSet,
FilterListViewSet,
InfractionViewSet,
+ FilterListViewSet,
+ FilterViewSet,
NominationViewSet,
OffensiveMessageViewSet,
AocAccountLinkViewSet,
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
index 262aa59f..33b65009 100644
--- a/pydis_site/apps/api/viewsets/bot/__init__.py
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -1,5 +1,8 @@
# flake8: noqa
-from .filter_list import FilterListViewSet
+from .filters import (
+ FilterListViewSet,
+ FilterViewSet
+)
from .bot_setting import BotSettingViewSet
from .bumped_thread import BumpedThreadViewSet
from .deleted_message import DeletedMessageViewSet
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 4b05acee..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.filter_list 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/<id:int>
- 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/<id:int>
- 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..dd9a7d87
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -0,0 +1,303 @@
+from rest_framework.viewsets import ModelViewSet
+
+from pydis_site.apps.api.models.bot.filters import ( # noqa: I101 - Preserving the filter order
+ FilterList,
+ Filter
+)
+from pydis_site.apps.api.serializers import ( # noqa: I101 - Preserving the filter order
+ FilterListSerializer,
+ FilterSerializer,
+)
+
+
+class FilterListViewSet(ModelViewSet):
+ """
+ View providing GET/DELETE 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": "invites",
+ ... "list_type": 1,
+ ... "filters": [
+ ... {
+ ... "id": 1,
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_field": None,
+ ... "filter_list": 1
+ ... "settings": {
+ ... "bypass_roles": None
+ ... "filter_dm": None,
+ ... "enabled": None
+ ... "send_alert": True,
+ ... "delete_messages": None
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": "",
+ ... "infraction_duration": None
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None
+ ... }
+ ... "mentions": {
+ ... "ping_type": None
+ ... "dm_ping_type": None
+ ... }
+ ... }
+ ...
+ ... },
+ ... ...
+ ... ],
+ ... "settings": {
+ ... "bypass_roles": [
+ ... "staff"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True
+ ... "delete_messages": True,
+ ... "send_alert": True
+ ... "infraction_and_notification": {
+ ... "infraction_type": "",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "dm_content": "",
+ ... "dm_embed": ""
+ ... }
+ ... "channel_scope": {
+ ... "disabled_channels": [],
+ ... "disabled_categories": [],
+ ... "enabled_channels": []
+ ... }
+ ... "mentions": {
+ ... "ping_type": [
+ ... "onduty"
+ ... ]
+ ... "dm_ping_type": []
+ ... }
+ ... },
+ ... ...
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+ - 401: returned if unauthenticated
+
+ ### GET /bot/filter/filter_lists/<id:int>
+ Returns a specific FilterList item from the database.
+
+ #### Response format
+ >>> {
+ ... "id": 1,
+ ... "name": "invites",
+ ... "list_type": 1,
+ ... "filters": [
+ ... {
+ ... "id": 1,
+ ... "filter_list": 1
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_field": None,
+ ... "settings": {
+ ... "bypass_roles": None
+ ... "filter_dm": None,
+ ... "enabled": None
+ ... "delete_messages": None,
+ ... "send_alert": None
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": "",
+ ... "infraction_duration": None
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None
+ ... }
+ ... "mentions": {
+ ... "ping_type": None
+ ... "dm_ping_type": None
+ ... }
+ ... }
+ ...
+ ... },
+ ... ...
+ ... ],
+ ... "settings": {
+ ... "bypass_roles": [
+ ... "staff"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True
+ ... "delete_messages": True
+ ... "send_alert": True
+ ... "infraction_and_notification": {
+ ... "infraction_type": "",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "dm_content": "",
+ ... "dm_embed": ""
+ ... }
+ ... "channel_scope": {
+ ... "disabled_channels": [],
+ ... "disabled_categories": [],
+ ... "enabled_channels": []
+ ... }
+ ... "mentions": {
+ ... "ping_type": [
+ ... "onduty"
+ ... ]
+ ... "dm_ping_type": []
+ ... }
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned if the id was not found.
+
+ ### DELETE /bot/filter/filter_lists/<id:int>
+ 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 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,
+ ... "filter_list": 1
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_field": None,
+ ... "settings": {
+ ... "bypass_roles": None
+ ... "filter_dm": None,
+ ... "enabled": None
+ ... "delete_messages": True,
+ ... "send_alert": True
+ ... "infraction": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None
+ ... },
+ ... "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
+ - 401: returned if unauthenticated
+
+ ### GET /bot/filter/filters/<id:int>
+ Returns a specific Filter item from the database.
+
+ #### Response format
+ >>> {
+ ... "id": 1,
+ ... "filter_list": 1
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_field": None,
+ ... "settings": {
+ ... "bypass_roles": None
+ ... "filter_dm": None,
+ ... "enabled": None
+ ... "delete_messages": True,
+ ... "send_alert": True
+ ... "infraction": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None
+ ... },
+ ... "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
+ - 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/<id:int>
+ 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/<id:int>
+ 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()