diff options
author | 2023-04-06 19:26:31 +0300 | |
---|---|---|
committer | 2023-04-06 19:26:31 +0300 | |
commit | 593a41e55e87fb811027e7a08be56a4097ab1fbc (patch) | |
tree | df62866382f4bdad7415b8bf3336ebd6134bc416 /pydis_site | |
parent | Bump sentry-sdk from 1.19.0 to 1.19.1 (#931) (diff) | |
parent | Make 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')
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.""" + + [email protected](Filter) +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() |