aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site
diff options
context:
space:
mode:
authorGravatar Boris Muratov <[email protected]>2023-04-06 19:26:31 +0300
committerGravatar GitHub <[email protected]>2023-04-06 19:26:31 +0300
commit593a41e55e87fb811027e7a08be56a4097ab1fbc (patch)
treedf62866382f4bdad7415b8bf3336ebd6134bc416 /pydis_site
parentBump sentry-sdk from 1.19.0 to 1.19.1 (#931) (diff)
parentMake additional_settings non-null with dict default (diff)
Merge pull request #861 from python-discord/new-filter-schema
New Filtering System
Diffstat (limited to 'pydis_site')
-rw-r--r--pydis_site/apps/api/admin.py12
-rw-r--r--pydis_site/apps/api/migrations/0088_new_filter_schema.py171
-rw-r--r--pydis_site/apps/api/migrations/0089_unique_constraint_filters.py26
-rw-r--r--pydis_site/apps/api/migrations/0090_unique_filter_list.py102
-rw-r--r--pydis_site/apps/api/migrations/0091_antispam_filter_list.py52
-rw-r--r--pydis_site/apps/api/models/__init__.py3
-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.py262
-rw-r--r--pydis_site/apps/api/serializers.py278
-rw-r--r--pydis_site/apps/api/tests/test_filterlists.py122
-rw-r--r--pydis_site/apps/api/tests/test_filters.py352
-rw-r--r--pydis_site/apps/api/tests/test_models.py11
-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.py499
18 files changed, 1774 insertions, 274 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/0088_new_filter_schema.py b/pydis_site/apps/api/migrations/0088_new_filter_schema.py
new file mode 100644
index 00000000..675fdcec
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0088_new_filter_schema.py
@@ -0,0 +1,171 @@
+"""Modified migration file to migrate existing filters to the new system."""
+from datetime import timedelta
+
+import django.contrib.postgres.fields
+from django.apps.registry import Apps
+from django.core.validators import MinValueValidator
+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
+
+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=[],
+ remove_context=(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="NONE",
+ infraction_reason="",
+ infraction_duration=timedelta(seconds=0),
+ infraction_channel=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,
+ created_at=object_.created_at,
+ updated_at=object_.updated_at,
+ filter_list=list_,
+ description=object_.comment,
+ additional_settings={},
+ guild_pings=None,
+ filter_dm=None,
+ dm_pings=None,
+ remove_context=None,
+ bypass_roles=None,
+ enabled=None,
+ dm_content=None,
+ dm_embed=None,
+ infraction_type=None,
+ infraction_reason=None,
+ infraction_duration=None,
+ infraction_channel=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', '0087_alter_mute_to_timeout'),
+ ]
+
+ 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')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('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_settings', models.JSONField(help_text='Additional settings which are specific to this filter.', default=dict)),
+ ('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)),
+ ('remove_context', models.BooleanField(help_text='Whether this filter should remove the context (such as a message) 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, blank=True)),
+ ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True, blank=True)),
+ ('infraction_type', models.CharField(choices=[('NONE', 'None'), ('NOTE', 'Note'), ('WARNING', 'Warning'), ('WATCH', 'Watch'), ('TIMEOUT', 'Timeout'), ('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, blank=True)),
+ ('infraction_duration', models.DurationField(help_text='The duration of the infraction. 0 for permanent.', null=True)),
+ ('infraction_channel', models.BigIntegerField(validators=(MinValueValidator(limit_value=0, message="Channel IDs cannot be negative."),), help_text="Channel in which to send the infraction.", 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 even if it's enabled in the category.", 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')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('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)),
+ ('remove_context', models.BooleanField(help_text='Whether this filter should remove the context (such as a message) 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, blank=True)),
+ ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, blank=True)),
+ ('infraction_type', models.CharField(choices=[('NONE', 'None'), ('NOTE', 'Note'), ('WARNING', 'Warning'), ('WATCH', 'Watch'), ('TIMEOUT', 'Timeout'), ('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)),
+ ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, blank=True)),
+ ('infraction_duration', models.DurationField(help_text='The duration of the infraction. 0 for permanent.')),
+ ('infraction_channel', models.BigIntegerField(validators=(MinValueValidator(limit_value=0, message="Channel IDs cannot be negative."),), help_text="Channel in which to send the infraction.")),
+ ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter even if it's enabled in the category.", 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/0089_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py
new file mode 100644
index 00000000..cb230a27
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py
@@ -0,0 +1,26 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0088_new_filter_schema'),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ "ALTER TABLE api_filter "
+ "ADD CONSTRAINT unique_filters UNIQUE NULLS NOT DISTINCT "
+ "(content, additional_settings, filter_list_id, dm_content, dm_embed, infraction_type, infraction_reason, infraction_duration, infraction_channel, guild_pings, filter_dm, dm_pings, remove_context, bypass_roles, enabled, send_alert, enabled_channels, disabled_channels, enabled_categories, disabled_categories)",
+ reverse_sql="ALTER TABLE api_filter DROP CONSTRAINT unique_filters",
+ state_operations=[
+ migrations.AddConstraint(
+ model_name='filter',
+ constraint=models.UniqueConstraint(
+ fields=('content', 'additional_settings', 'filter_list', 'dm_content', 'dm_embed', 'infraction_type', 'infraction_reason', 'infraction_duration', 'infraction_channel', 'guild_pings', 'filter_dm', 'dm_pings', 'remove_context', 'bypass_roles', 'enabled', 'send_alert', 'enabled_channels', 'disabled_channels', 'enabled_categories', 'disabled_categories'),
+ name='unique_filters'
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0090_unique_filter_list.py b/pydis_site/apps/api/migrations/0090_unique_filter_list.py
new file mode 100644
index 00000000..cef2faa3
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0090_unique_filter_list.py
@@ -0,0 +1,102 @@
+from datetime import timedelta
+
+from django.apps.registry import Apps
+from django.db import migrations
+
+import pydis_site.apps.api.models.bot.filters
+
+
+def create_unique_list(apps: Apps, _):
+ """Create the 'unique' FilterList and its related Filters."""
+ filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
+ filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
+
+ list_ = filter_list.objects.create(
+ name="unique",
+ list_type=0,
+ guild_pings=[],
+ filter_dm=True,
+ dm_pings=[],
+ remove_context=False,
+ bypass_roles=[],
+ enabled=True,
+ dm_content="",
+ dm_embed="",
+ infraction_type="NONE",
+ infraction_reason="",
+ infraction_duration=timedelta(seconds=0),
+ infraction_channel=0,
+ disabled_channels=[],
+ disabled_categories=[],
+ enabled_channels=[],
+ enabled_categories=[],
+ send_alert=True
+ )
+
+ everyone = filter_.objects.create(
+ content="everyone",
+ filter_list=list_,
+ description="",
+ remove_context=True,
+ bypass_roles=["Helpers"],
+ dm_content=(
+ "Please don't try to ping `@everyone` or `@here`. Your message has been removed. "
+ "If you believe this was a mistake, please let staff know!"
+ ),
+ disabled_categories=["CODE JAM"]
+ )
+ everyone.save()
+
+ webhook = filter_.objects.create(
+ content="webhook",
+ filter_list=list_,
+ description="",
+ remove_context=True,
+ dm_content=(
+ "Looks like you posted a Discord webhook URL. "
+ "Therefore, your message has been removed, and your webhook has been deleted. "
+ "You can re-create it if you wish to. "
+ "If you believe this was a mistake, please let us know."
+ ),
+ )
+ webhook.save()
+
+ rich_embed = filter_.objects.create(
+ content="rich_embed",
+ filter_list=list_,
+ description="",
+ guild_pings=["Moderators"],
+ dm_pings=["Moderators"]
+ )
+ rich_embed.save()
+
+ discord_token = filter_.objects.create(
+ content="discord_token",
+ filter_list=list_,
+ filter_dm=False,
+ remove_context=True,
+ dm_content=(
+ "I noticed you posted a seemingly valid Discord API "
+ "token in your message and have removed your message. "
+ "This means that your token has been **compromised**. "
+ "Please change your token **immediately** at: "
+ "<https://discord.com/developers/applications>\n\n"
+ "Feel free to re-post it with the token removed. "
+ "If you believe this was a mistake, please let us know!"
+ )
+ )
+ discord_token.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0089_unique_constraint_filters'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=create_unique_list,
+ reverse_code=None
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0091_antispam_filter_list.py b/pydis_site/apps/api/migrations/0091_antispam_filter_list.py
new file mode 100644
index 00000000..7c233142
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0091_antispam_filter_list.py
@@ -0,0 +1,52 @@
+from datetime import timedelta
+
+from django.apps.registry import Apps
+from django.db import migrations
+
+import pydis_site.apps.api.models.bot.filters
+
+
+def create_antispam_list(apps: Apps, _):
+ """Create the 'antispam' FilterList and its related Filters."""
+ filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
+ filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
+
+ list_ = filter_list.objects.create(
+ name="antispam",
+ list_type=0,
+ guild_pings=["Moderators"],
+ filter_dm=False,
+ dm_pings=[],
+ remove_context=True,
+ bypass_roles=["Helpers"],
+ enabled=True,
+ dm_content="",
+ dm_embed="",
+ infraction_type="TIMEOUT",
+ infraction_reason="",
+ infraction_duration=timedelta(seconds=600),
+ infraction_channel=0,
+ disabled_channels=[],
+ disabled_categories=["CODE JAM"],
+ enabled_channels=[],
+ enabled_categories=[],
+ send_alert=True
+ )
+
+ rules = ("duplicates", "attachments", "burst", "chars", "emoji", "links", "mentions", "newlines", "role_mentions")
+
+ filter_.objects.bulk_create([filter_(content=rule, filter_list=list_) for rule in rules])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0090_unique_filter_list'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=create_antispam_list,
+ reverse_code=None
+ ),
+ ]
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index a197e988..fee4c8d5 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -1,10 +1,11 @@
# flake8: noqa
from .bot import (
+ FilterList,
+ Filter,
BotSetting,
BumpedThread,
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 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..71f8771f
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -0,0 +1,262 @@
+from django.contrib.postgres.fields import ArrayField
+from django.core.validators import MinValueValidator
+from django.db import models
+from django.db.models import UniqueConstraint
+
+# Must be imported that way to avoid circular imports
+from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin
+from .infraction import Infraction
+
+
+class FilterListType(models.IntegerChoices):
+ """Choice between allow or deny for a list type."""
+
+ ALLOW = 1
+ DENY = 0
+
+
+class FilterList(ModelTimestampMixin, ModelReprMixin, 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"
+ )
+ dm_content = models.CharField(
+ max_length=1000,
+ null=False,
+ blank=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=False,
+ blank=True
+ )
+ infraction_type = models.CharField(
+ choices=[
+ (choices[0].upper(), choices[1])
+ for choices in [("NONE", "None"), *Infraction.TYPE_CHOICES]
+ ],
+ max_length=10,
+ null=False,
+ 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.",
+ blank=True,
+ null=False
+ )
+ infraction_duration = models.DurationField(
+ null=False,
+ help_text="The duration of the infraction. 0 for permanent."
+ )
+ infraction_channel = models.BigIntegerField(
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Channel IDs cannot be negative."
+ ),
+ ),
+ help_text="Channel in which to send the infraction.",
+ null=False
+ )
+ 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
+ )
+ remove_context = models.BooleanField(
+ help_text=(
+ "Whether this filter should remove the context (such as a message) "
+ "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 even if it's enabled in the category."
+ )
+ 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(ModelTimestampMixin, ModelReprMixin, 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.", null=True
+ )
+ additional_settings = models.JSONField(
+ help_text="Additional settings which are specific to this filter.", default=dict
+ )
+ filter_list = models.ForeignKey(
+ FilterList, models.CASCADE, related_name="filters",
+ help_text="The filter list containing this filter."
+ )
+ dm_content = models.CharField(
+ max_length=1000,
+ null=True,
+ blank=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,
+ blank=True
+ )
+ infraction_type = models.CharField(
+ choices=[
+ (choices[0].upper(), choices[1])
+ for choices in [("NONE", "None"), *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.",
+ blank=True,
+ null=True
+ )
+ infraction_duration = models.DurationField(
+ null=True,
+ help_text="The duration of the infraction. 0 for permanent."
+ )
+ infraction_channel = models.BigIntegerField(
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Channel IDs cannot be negative."
+ ),
+ ),
+ help_text="Channel in which to send the infraction.",
+ null=True
+ )
+ 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
+ )
+ remove_context = models.BooleanField(
+ help_text=(
+ "Whether this filter should remove the context (such as a message) "
+ "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
+ )
+
+ 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 even if it's enabled in the category.",
+ 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 not in ("id", "description", "created_at", "updated_at")
+ ),
+ name="unique_filters"),
+ )
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index e74ca102..2186b02c 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -1,4 +1,7 @@
"""Converters from Django models to data interchange formats and back."""
+from datetime import timedelta
+from typing import Any
+
from django.db.models.query import QuerySet
from django.db.utils import IntegrityError
from rest_framework.exceptions import NotFound
@@ -19,6 +22,7 @@ from .models import (
BumpedThread,
DeletedMessage,
DocumentationLink,
+ Filter,
FilterList,
Infraction,
MessageDeletionContext,
@@ -141,30 +145,286 @@ class DocumentationLinkSerializer(ModelSerializer):
fields = ('package', 'base_url', 'inventory_url')
+# region: filters serializers
+
+SETTINGS_FIELDS = (
+ 'dm_content',
+ 'dm_embed',
+ 'infraction_type',
+ 'infraction_reason',
+ 'infraction_duration',
+ 'infraction_channel',
+ 'guild_pings',
+ 'filter_dm',
+ 'dm_pings',
+ 'remove_context',
+ 'send_alert',
+ 'bypass_roles',
+ 'enabled',
+ 'enabled_channels',
+ 'disabled_channels',
+ 'enabled_categories',
+ 'disabled_categories',
+)
+
+ALLOW_BLANK_SETTINGS = (
+ 'dm_content',
+ 'dm_embed',
+ 'infraction_reason',
+)
+
+ALLOW_EMPTY_SETTINGS = (
+ 'enabled_channels',
+ 'disabled_channels',
+ 'enabled_categories',
+ 'disabled_categories',
+ 'guild_pings',
+ 'dm_pings',
+ 'bypass_roles',
+)
+
+# Required fields for custom JSON representation purposes
+BASE_FILTER_FIELDS = (
+ 'id', 'created_at', 'updated_at', 'content', 'description', 'additional_settings'
+)
+BASE_FILTERLIST_FIELDS = ('id', 'created_at', 'updated_at', 'name', 'list_type')
+BASE_SETTINGS_FIELDS = (
+ 'bypass_roles',
+ 'filter_dm',
+ 'enabled',
+ 'remove_context',
+ 'send_alert'
+)
+INFRACTION_AND_NOTIFICATION_FIELDS = (
+ 'infraction_type',
+ 'infraction_reason',
+ 'infraction_duration',
+ 'infraction_channel',
+ 'dm_content',
+ 'dm_embed'
+)
+CHANNEL_SCOPE_FIELDS = (
+ 'disabled_channels',
+ 'disabled_categories',
+ 'enabled_channels',
+ 'enabled_categories'
+)
+MENTIONS_FIELDS = ('guild_pings', 'dm_pings')
+
+MAX_TIMEOUT_DURATION = timedelta(days=28)
+
+
+def _create_meta_extra_kwargs(*, for_filter: bool) -> dict[str, dict[str, bool]]:
+ """Create the extra kwargs for the Meta classes of the Filter and FilterList serializers."""
+ extra_kwargs = {}
+ for field in SETTINGS_FIELDS:
+ field_args = {'required': False, 'allow_null': True} if for_filter else {}
+ if field in ALLOW_BLANK_SETTINGS:
+ field_args['allow_blank'] = True
+ if field in ALLOW_EMPTY_SETTINGS:
+ field_args['allow_empty'] = True
+ extra_kwargs[field] = field_args
+ return extra_kwargs
+
+
+def get_field_value(data: dict, field_name: str) -> Any:
+ """Get the value directly from the key, or from the filter list if it's missing or is None."""
+ if data.get(field_name) is not None:
+ return data[field_name]
+ return getattr(data['filter_list'], field_name)
+
+
+class FilterSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `Filter` instances."""
+
+ def validate(self, data: dict) -> dict:
+ """Perform infraction data + allowed and disallowed lists validation."""
+ infraction_type = get_field_value(data, 'infraction_type')
+ infraction_duration = get_field_value(data, 'infraction_duration')
+ if (
+ (get_field_value(data, 'infraction_reason') or infraction_duration)
+ and infraction_type == 'NONE'
+ ):
+ raise ValidationError(
+ "Infraction type is required with infraction duration or reason."
+ )
+
+ if (
+ infraction_type == 'TIMEOUT'
+ and (not infraction_duration or infraction_duration > MAX_TIMEOUT_DURATION)
+ ):
+ raise ValidationError(
+ f"A timeout cannot be longer than {MAX_TIMEOUT_DURATION.days} days."
+ )
+
+ common_channels = (
+ set(get_field_value(data, 'disabled_channels'))
+ & set(get_field_value(data, 'enabled_channels'))
+ )
+ if common_channels:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled channels lists:"
+ f" {', '.join(repr(channel) for channel in common_channels)}."
+ )
+
+ common_categories = (
+ set(get_field_value(data, 'disabled_categories'))
+ & set(get_field_value(data, 'enabled_categories'))
+ )
+ if common_categories:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled categories lists:"
+ f" {', '.join(repr(category) for category in common_categories)}."
+ )
+
+ return data
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ model = Filter
+ fields = (
+ 'id',
+ 'created_at',
+ 'updated_at',
+ 'content',
+ 'description',
+ 'additional_settings',
+ 'filter_list'
+ ) + SETTINGS_FIELDS
+ extra_kwargs = _create_meta_extra_kwargs(for_filter=True)
+
+ def create(self, validated_data: dict) -> User:
+ """Override the create method to catch violations of the custom uniqueness constraint."""
+ try:
+ return super().create(validated_data)
+ except IntegrityError:
+ raise ValidationError(
+ "Check if a filter with this combination of content "
+ "and settings already exists in this filter list."
+ )
+
+ 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`.
+ """
+ settings = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
+ settings['infraction_and_notification'] = {
+ name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS
+ }
+ settings['channel_scope'] = {
+ name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS
+ }
+ settings['mentions'] = {
+ name: getattr(instance, name) for name in MENTIONS_FIELDS
+ }
+
+ schema = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS}
+ schema['filter_list'] = instance.filter_list.id
+ schema['settings'] = settings
+ return schema
+
+
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."""
+ infraction_duration = data.get('infraction_duration')
+ if (
+ data.get('infraction_reason') or 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
+ ):
+ common_channels = set(data['disabled_channels']) & set(data['enabled_channels'])
+ if common_channels:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled channels lists:"
+ f" {', '.join(repr(channel) for channel in common_channels)}."
+ )
+
+ if (
+ data.get('infraction_type') == 'TIMEOUT'
+ and (not infraction_duration or infraction_duration > MAX_TIMEOUT_DURATION)
+ ):
+ raise ValidationError(
+ f"A timeout cannot be longer than {MAX_TIMEOUT_DURATION.days} days."
+ )
+
+ if (
+ data.get('disabled_categories') is not None
+ and data.get('enabled_categories') is not None
+ ):
+ common_categories = set(data['disabled_categories']) & set(data['enabled_categories'])
+ if common_categories:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled categories lists:"
+ f" {', '.join(repr(category) for category in common_categories)}."
+ )
+
+ return data
+
class Meta:
"""Metadata defined for the Django REST Framework."""
model = FilterList
- fields = ('id', 'created_at', 'updated_at', 'type', 'allowed', 'content', 'comment')
+ fields = (
+ 'id', 'created_at', 'updated_at', 'name', 'list_type', 'filters'
+ ) + SETTINGS_FIELDS
+ extra_kwargs = _create_meta_extra_kwargs(for_filter=False)
- # 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 there can only be one filter list with the same name and type.
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 exists."
)
),
]
+ 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 groups.
+
+ Furthermore, it puts the fields that are meant to represent FilterList settings,
+ into a sub-field called `settings`.
+ """
+ schema = {name: getattr(instance, name) for name in BASE_FILTERLIST_FIELDS}
+ schema['filters'] = [
+ FilterSerializer(many=False).to_representation(instance=item)
+ for item in Filter.objects.filter(filter_list=instance.id)
+ ]
+
+ settings = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
+ settings['infraction_and_notification'] = {
+ name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS
+ }
+ settings['channel_scope'] = {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}
+ settings['mentions'] = {name: getattr(instance, name) for name in MENTIONS_FIELDS}
+
+ schema['settings'] = settings
+ return schema
+
+# endregion
+
class InfractionSerializer(ModelSerializer):
"""A class providing (de-)serialization of `Infraction` instances."""
@@ -204,7 +464,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..5059d651
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_filters.py
@@ -0,0 +1,352 @@
+import contextlib
+from dataclasses import dataclass
+from datetime import timedelta
+from typing import Any, Dict, Tuple, Type
+
+from django.db.models import Model
+from django.urls import reverse
+
+from pydis_site.apps.api.models.bot.filters import Filter, FilterList
+from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase
+
+
+@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'api:bot:{self.route}-{"detail" if detail else "list"}')
+
+
+FK_FIELDS: Dict[Type[Model], Tuple[str, ...]] = {
+ FilterList: (),
+ Filter: ("filter_list",),
+}
+
+
+def get_test_sequences() -> Dict[str, TestSequence]:
+ filter_list1_deny_dict = {
+ "name": "testname",
+ "list_type": 0,
+ "guild_pings": [],
+ "filter_dm": True,
+ "dm_pings": [],
+ "remove_context": False,
+ "bypass_roles": [],
+ "enabled": True,
+ "dm_content": "",
+ "dm_embed": "",
+ "infraction_type": "NONE",
+ "infraction_reason": "",
+ "infraction_duration": timedelta(seconds=0),
+ "infraction_channel": 0,
+ "disabled_channels": [],
+ "disabled_categories": [],
+ "enabled_channels": [],
+ "enabled_categories": [],
+ "send_alert": True
+ }
+ filter_list1_allow_dict = filter_list1_deny_dict.copy()
+ filter_list1_allow_dict["list_type"] = 1
+ filter_list1_allow = FilterList(**filter_list1_allow_dict)
+
+ return {
+ "filter_list1": TestSequence(
+ FilterList,
+ "filterlist",
+ filter_list1_deny_dict,
+ ignored_fields=("filters", "created_at", "updated_at")
+ ),
+ "filter_list2": TestSequence(
+ FilterList,
+ "filterlist",
+ {
+ "name": "testname2",
+ "list_type": 1,
+ "guild_pings": ["Moderators"],
+ "filter_dm": False,
+ "dm_pings": ["here"],
+ "remove_context": True,
+ "bypass_roles": ["123456"],
+ "enabled": False,
+ "dm_content": "testing testing",
+ "dm_embed": "one two three",
+ "infraction_type": "TIMEOUT",
+ "infraction_reason": "stop testing",
+ "infraction_duration": timedelta(seconds=10.5),
+ "infraction_channel": 123,
+ "disabled_channels": ["python-general"],
+ "disabled_categories": ["CODE JAM"],
+ "enabled_channels": ["mighty-mice"],
+ "enabled_categories": ["Lobby"],
+ "send_alert": False
+ },
+ ignored_fields=("filters", "created_at", "updated_at")
+ ),
+ "filter": TestSequence(
+ Filter,
+ "filter",
+ {
+ "content": "bad word",
+ "description": "This is a really bad word.",
+ "additional_settings": "{'hi': 'there'}",
+ "guild_pings": None,
+ "filter_dm": None,
+ "dm_pings": None,
+ "remove_context": None,
+ "bypass_roles": None,
+ "enabled": None,
+ "dm_content": None,
+ "dm_embed": None,
+ "infraction_type": None,
+ "infraction_reason": None,
+ "infraction_duration": None,
+ "infraction_channel": None,
+ "disabled_channels": None,
+ "disabled_categories": None,
+ "enabled_channels": None,
+ "enabled_categories": None,
+ "send_alert": None,
+ "filter_list": filter_list1_allow
+ },
+ ignored_fields=("created_at", "updated_at")
+ ),
+ }
+
+
+def save_nested_objects(object_: Model, save_root: bool = True) -> None:
+ for field in FK_FIELDS.get(object_.__class__, ()):
+ value = getattr(object_, field)
+ 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
+ elif isinstance(value, timedelta):
+ json[key] = str(value.total_seconds())
+
+ 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
+
+
+def flatten_settings(json: dict) -> dict:
+ settings = json.pop("settings", {})
+ flattened_settings = {}
+ for entry, value in settings.items():
+ if isinstance(value, dict):
+ flattened_settings.update(value)
+ else:
+ flattened_settings[entry] = value
+
+ json.update(flattened_settings)
+
+ return json
+
+
+class GenericFilterTests(AuthenticatedAPITestCase):
+
+ 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(flatten_settings(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(flatten_settings(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(flatten_settings(response.json()), sequence),
+ clean_test_json(sequence.object)
+ )
+
+ def test_creation_missing_field(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ ignored_fields = sequence.ignored_fields + ("id", "additional_settings")
+ with self.subTest(name=name):
+ saved = sequence.model(**sequence.object)
+ save_nested_objects(saved)
+ 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 ignored_fields:
+ 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)
+
+
+class FilterValidationTests(AuthenticatedAPITestCase):
+
+ def test_filter_validation(self) -> None:
+ test_sequences = get_test_sequences()
+ base_filter = test_sequences["filter"]
+ base_filter_list = test_sequences["filter_list1"]
+ cases = (
+ ({"infraction_reason": "hi"}, {}, 400),
+ ({"infraction_duration": timedelta(seconds=10)}, {}, 400),
+ ({"infraction_reason": "hi"}, {"infraction_type": "NOTE"}, 200),
+ ({"infraction_type": "TIMEOUT", "infraction_duration": timedelta(days=30)}, {}, 400),
+ ({"infraction_duration": timedelta(seconds=10)}, {"infraction_type": "TIMEOUT"}, 200),
+ ({"enabled_channels": ["admins"]}, {}, 200),
+ ({"disabled_channels": ["123"]}, {}, 200),
+ ({"enabled_categories": ["CODE JAM"]}, {}, 200),
+ ({"disabled_categories": ["CODE JAM"]}, {}, 200),
+ ({"enabled_channels": ["admins"], "disabled_channels": ["123", "admins"]}, {}, 400),
+ ({"enabled_categories": ["admins"], "disabled_categories": ["123", "admins"]}, {}, 400),
+ ({"enabled_channels": ["admins"]}, {"disabled_channels": ["123", "admins"]}, 400),
+ ({"enabled_categories": ["admins"]}, {"disabled_categories": ["123", "admins"]}, 400),
+ )
+
+ for filter_settings, filter_list_settings, response_code in cases:
+ with self.subTest(
+ f_settings=filter_settings, fl_settings=filter_list_settings, response=response_code
+ ):
+ base_filter.model.objects.all().delete()
+ base_filter_list.model.objects.all().delete()
+
+ case_filter_dict = base_filter.object.copy()
+ case_fl_dict = base_filter_list.object.copy()
+ case_fl_dict.update(filter_list_settings)
+
+ case_fl = base_filter_list.model(**case_fl_dict)
+ case_filter_dict["filter_list"] = case_fl
+ case_filter = base_filter.model(**case_filter_dict)
+ save_nested_objects(case_filter)
+
+ filter_settings["filter_list"] = case_fl
+ response = self.client.patch(
+ f"{base_filter.url()}/{case_filter.id}", data=clean_test_json(filter_settings)
+ )
+ self.assertEqual(response.status_code, response_code)
+
+ def test_filter_list_validation(self) -> None:
+ test_sequences = get_test_sequences()
+ base_filter_list = test_sequences["filter_list1"]
+ cases = (
+ ({"infraction_reason": "hi"}, 400),
+ ({"infraction_duration": timedelta(seconds=10)}, 400),
+ ({"infraction_type": "TIMEOUT", "infraction_duration": timedelta(days=30)}, 400),
+ ({"infraction_reason": "hi", "infraction_type": "NOTE"}, 200),
+ ({"infraction_duration": timedelta(seconds=10), "infraction_type": "TIMEOUT"}, 200),
+ ({"enabled_channels": ["admins"]}, 200), ({"disabled_channels": ["123"]}, 200),
+ ({"enabled_categories": ["CODE JAM"]}, 200),
+ ({"disabled_categories": ["CODE JAM"]}, 200),
+ ({"enabled_channels": ["admins"], "disabled_channels": ["123", "admins"]}, 400),
+ ({"enabled_categories": ["admins"], "disabled_categories": ["123", "admins"]}, 400),
+ )
+
+ for filter_list_settings, response_code in cases:
+ with self.subTest(fl_settings=filter_list_settings, response=response_code):
+ base_filter_list.model.objects.all().delete()
+
+ case_fl_dict = base_filter_list.object.copy()
+ case_fl = base_filter_list.model(**case_fl_dict)
+ save_nested_objects(case_fl)
+
+ response = self.client.patch(
+ f"{base_filter_list.url()}/{case_fl.id}",
+ data=clean_test_json(filter_list_settings)
+ )
+ self.assertEqual(response.status_code, response_code)
+
+ def test_filter_unique_constraint(self) -> None:
+ test_filter = get_test_sequences()["filter"]
+ test_filter.model.objects.all().delete()
+ test_filter_object = test_filter.model(**test_filter.object)
+ save_nested_objects(test_filter_object, False)
+
+ response = self.client.post(test_filter.url(), data=clean_test_json(test_filter.object))
+ self.assertEqual(response.status_code, 201)
+
+ response = self.client.post(test_filter.url(), data=clean_test_json(test_filter.object))
+ self.assertEqual(response.status_code, 400)
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index c07d59cd..d3341b35 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -6,6 +6,8 @@ from django.test import SimpleTestCase, TestCase
from pydis_site.apps.api.models import (
DeletedMessage,
DocumentationLink,
+ Filter,
+ FilterList,
Infraction,
MessageDeletionContext,
Nomination,
@@ -104,6 +106,15 @@ class StringDunderMethodTests(SimpleTestCase):
DocumentationLink(
'test', 'http://example.com', 'http://example.com'
),
+ FilterList(
+ name="forbidden_duckies",
+ list_type=0,
+ ),
+ Filter(
+ content="ducky_nsfw",
+ description="This ducky is totally inappropriate!",
+ additional_settings=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..d6c2d18c
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -0,0 +1,499 @@
+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,
+ ... "created_at": "2023-01-27T21:26:34.027293Z",
+ ... "updated_at": "2023-01-27T21:26:34.027308Z",
+ ... "name": "invite",
+ ... "list_type": 1,
+ ... "filters": [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ],
+ ... "settings": {
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_and_notification": {
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": ""
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... },
+ ... "mentions": {
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ]
+
+ #### 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,
+ ... "created_at": "2023-01-27T21:26:34.027293Z",
+ ... "updated_at": "2023-01-27T21:26:34.027308Z",
+ ... "name": "invite",
+ ... "list_type": 1,
+ ... "filters": [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ],
+ ... "settings": {
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_and_notification": {
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": ""
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... },
+ ... "mentions": {
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+ ... }
+ ... }
+
+ #### 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": "invite",
+ ... "list_type": 1,
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": "",
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### PATCH /bot/filter/filter_lists/<id:int>
+ Updates a specific FilterList item from the database.
+
+ #### Response format
+ >>> {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.027293Z",
+ ... "updated_at": "2023-01-27T21:26:34.027308Z",
+ ... "name": "invite",
+ ... "list_type": 1,
+ ... "filters": [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ],
+ ... "settings": {
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_and_notification": {
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": ""
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... },
+ ... "mentions": {
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+ ... }
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### DELETE /bot/filter/filter_lists/<id:int>
+ Deletes the FilterList item with the given `id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a FilterList 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,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": 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,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": 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
+ >>> {
+ ... "filter_list": 1,
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": False,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+
+ #### 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,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... }
+
+ #### 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 Filter with the given `id` does not exist
+ """
+
+ serializer_class = FilterSerializer
+ queryset = Filter.objects.all()