diff options
| -rw-r--r-- | pydis_site/apps/api/admin.py | 12 | ||||
| -rw-r--r-- | pydis_site/apps/api/migrations/0085_new_filter_schema.py | 160 | ||||
| -rw-r--r-- | pydis_site/apps/api/migrations/0086_unique_constraint_filters.py | 37 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/__init__.py | 2 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/__init__.py | 2 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/filter_list.py | 42 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/filters.py | 205 | ||||
| -rw-r--r-- | pydis_site/apps/api/serializers.py | 209 | ||||
| -rw-r--r-- | pydis_site/apps/api/tests/test_filterlists.py | 122 | ||||
| -rw-r--r-- | pydis_site/apps/api/tests/test_filters.py | 298 | ||||
| -rw-r--r-- | pydis_site/apps/api/tests/test_models.py | 14 | ||||
| -rw-r--r-- | pydis_site/apps/api/urls.py | 9 | ||||
| -rw-r--r-- | pydis_site/apps/api/viewsets/__init__.py | 2 | ||||
| -rw-r--r-- | pydis_site/apps/api/viewsets/bot/__init__.py | 5 | ||||
| -rw-r--r-- | pydis_site/apps/api/viewsets/bot/filter_list.py | 98 | ||||
| -rw-r--r-- | pydis_site/apps/api/viewsets/bot/filters.py | 303 | 
16 files changed, 1245 insertions, 275 deletions
| diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py index 2aca38a1..e123d150 100644 --- a/pydis_site/apps/api/admin.py +++ b/pydis_site/apps/api/admin.py @@ -13,6 +13,8 @@ from .models import (      BotSetting,      DeletedMessage,      DocumentationLink, +    Filter, +    FilterList,      Infraction,      MessageDeletionContext,      Nomination, @@ -194,6 +196,16 @@ class DeletedMessageInline(admin.TabularInline):      model = DeletedMessage [email protected](FilterList) +class FilterListAdmin(admin.ModelAdmin): +    """Admin formatting for the FilterList model.""" + + [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/0085_new_filter_schema.py b/pydis_site/apps/api/migrations/0085_new_filter_schema.py new file mode 100644 index 00000000..d16c26ac --- /dev/null +++ b/pydis_site/apps/api/migrations/0085_new_filter_schema.py @@ -0,0 +1,160 @@ +# Modified migration file to migrate existing filters to the new one +from datetime import timedelta + +import django.contrib.postgres.fields +from django.apps.registry import Apps +from django.db import migrations, models +import django.db.models.deletion +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import pydis_site.apps.api.models.bot.filters + +OLD_LIST_NAMES = (('GUILD_INVITE', True), ('GUILD_INVITE', False), ('FILE_FORMAT', True), ('DOMAIN_NAME', False), ('FILTER_TOKEN', False), ('REDIRECT', False)) +change_map = { +    "FILTER_TOKEN": "token", +    "DOMAIN_NAME": "domain", +    "GUILD_INVITE": "invite", +    "FILE_FORMAT": "extension", +    "REDIRECT": "redirect" +} + + +def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: +    filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter") +    filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList") +    filter_list_old = apps.get_model("api", "FilterListOld") + +    for name, type_ in OLD_LIST_NAMES: +        objects = filter_list_old.objects.filter(type=name, allowed=type_) +        if name == "DOMAIN_NAME": +            dm_content = "Your message has been removed because it contained a blocked domain: `{domain}`." +        elif name == "GUILD_INVITE": +            dm_content = "Per Rule 6, your invite link has been removed. " \ +                         "Our server rules can be found here: https://pythondiscord.com/pages/rules" +        else: +            dm_content = "" + +        list_ = filter_list.objects.create( +            name=change_map[name], +            list_type=int(type_), +            guild_pings=(["Moderators"] if name != "FILE_FORMAT" else []), +            filter_dm=True, +            dm_pings=[], +            delete_messages=(True if name != "FILTER_TOKEN" else False), +            bypass_roles=["Helpers"], +            enabled=True, +            dm_content=dm_content, +            dm_embed="" if name != "FILE_FORMAT" else "*Defined at runtime.*", +            infraction_type="", +            infraction_reason="", +            infraction_duration=timedelta(seconds=0), +            disabled_channels=[], +            disabled_categories=(["CODE JAM"] if name in ("FILE_FORMAT", "GUILD_INVITE") else []), +            enabled_channels=[], +            enabled_categories=[], +            send_alert=(name in ('GUILD_INVITE', 'DOMAIN_NAME', 'FILTER_TOKEN')) +        ) + +        for object_ in objects: +            new_object = filter_.objects.create( +                content=object_.content, +                filter_list=list_, +                description=object_.comment, +                additional_field=None, +                guild_pings=None, +                filter_dm=None, +                dm_pings=None, +                delete_messages=None, +                bypass_roles=None, +                enabled=None, +                dm_content=None, +                dm_embed=None, +                infraction_type=None, +                infraction_reason=None, +                infraction_duration=None, +                disabled_channels=None, +                disabled_categories=None, +                enabled_channels=None, +                enabled_categories=None, +                send_alert=None, +            ) +            new_object.save() + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0084_infraction_last_applied'), +    ] + +    operations = [ +        migrations.RenameModel( +            old_name='FilterList', +            new_name='FilterListOld' +        ), +        migrations.CreateModel( +            name='Filter', +            fields=[ +                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), +                ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), +                ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)), +                ('additional_field', models.JSONField(help_text='Implementation specific field.', null=True)), +                ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, null=True)), +                ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), +                ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, null=True)), +                ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)), +                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, null=True)), +                ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)), +                ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), +                ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)), +                ('infraction_type', models.CharField(choices=[('NOTE', 'Note'), ('WARNING', 'Warning'), ('WATCH', 'Watch'), ('MUTE', 'Mute'), ('KICK', 'Kick'), ('BAN', 'Ban'), ('SUPERSTAR', 'Superstar'), ('VOICE_BAN', 'Voice Ban'), ('VOICE_MUTE', 'Voice Mute')], help_text='The infraction to apply to this user.', max_length=10, null=True)), +                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True)), +                ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), +                ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter.", null=True, size=None)), +                ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", null=True, size=None)), +                ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", null=True, size=None)), +                ('enabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="The only categories in which to run the filter.", null=True, size=None)), +                ('send_alert', models.BooleanField(help_text='Whether an alert should be sent.', null=True)), +            ], +        ), +        migrations.CreateModel( +            name='FilterList', +            fields=[ +                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), +                ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), +                ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')), +                ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None)), +                ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')), +                ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None)), +                ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')), +                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None)), +                ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')), +                ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), +                ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)), +                ('infraction_type', models.CharField(choices=[('NOTE', 'Note'), ('WARNING', 'Warning'), ('WATCH', 'Watch'), ('MUTE', 'Mute'), ('KICK', 'Kick'), ('BAN', 'Ban'), ('SUPERSTAR', 'Superstar'), ('VOICE_BAN', 'Voice Ban'), ('VOICE_MUTE', 'Voice Mute')], help_text='The infraction to apply to this user.', max_length=10, null=True)), +                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True)), +                ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), +                ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter.", size=None)), +                ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", size=None)), +                ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", size=None)), +                ('enabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="The only categories in which to run the filter.", size=None)), +                ('send_alert', models.BooleanField(help_text='Whether an alert should be sent.')), +            ], +        ), +        migrations.AddField( +            model_name='filter', +            name='filter_list', +            field=models.ForeignKey(help_text='The filter list containing this filter.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='api.FilterList'), +        ), +        migrations.AddConstraint( +            model_name='filterlist', +            constraint=models.UniqueConstraint(fields=('name', 'list_type'), name='unique_name_type'), +        ), +        migrations.RunPython( +            code=forward,  # Core of the migration +            reverse_code=lambda *_: None +        ), +        migrations.DeleteModel( +            name='FilterListOld' +        ) +    ] diff --git a/pydis_site/apps/api/migrations/0086_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0086_unique_constraint_filters.py new file mode 100644 index 00000000..8072ed2e --- /dev/null +++ b/pydis_site/apps/api/migrations/0086_unique_constraint_filters.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1.14 on 2022-03-22 16:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0085_new_filter_schema'), +    ] + +    operations = [ +        migrations.AddConstraint( +            model_name='filter', +            constraint=models.UniqueConstraint(fields=( +                'dm_content', +                'dm_embed', +                'infraction_type', +                'infraction_reason', +                'infraction_duration', +                'content', +                'additional_field', +                'filter_list', +                'guild_pings', +                'filter_dm', +                'dm_pings', +                'delete_messages', +                'bypass_roles', +                'enabled', +                'send_alert', +                'enabled_channels', +                'disabled_channels', +                'enabled_categories', +                'disabled_categories' +            ), name='unique_filters'), +        ), +    ] diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index a197e988..580c95a0 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -1,5 +1,7 @@  # flake8: noqa  from .bot import ( +    FilterList, +    Filter,      BotSetting,      BumpedThread,      DocumentationLink, diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index 013bb85e..6f09473d 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -1,9 +1,9 @@  # flake8: noqa +from .filters import FilterList, Filter  from .bot_setting import BotSetting  from .bumped_thread import BumpedThread  from .deleted_message import DeletedMessage  from .documentation_link import DocumentationLink -from .filter_list import FilterList  from .infraction import Infraction  from .message import Message  from .aoc_completionist_block import AocCompletionistBlock diff --git a/pydis_site/apps/api/models/bot/filter_list.py b/pydis_site/apps/api/models/bot/filter_list.py deleted file mode 100644 index d30f7213..00000000 --- a/pydis_site/apps/api/models/bot/filter_list.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.db import models - -from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin - - -class FilterList(ModelTimestampMixin, ModelReprMixin, models.Model): -    """An item that is either allowed or denied.""" - -    FilterListType = models.TextChoices( -        'FilterListType', -        'GUILD_INVITE ' -        'FILE_FORMAT ' -        'DOMAIN_NAME ' -        'FILTER_TOKEN ' -        'REDIRECT ' -    ) -    type = models.CharField( -        max_length=50, -        help_text="The type of allowlist this is on.", -        choices=FilterListType.choices, -    ) -    allowed = models.BooleanField( -        help_text="Whether this item is on the allowlist or the denylist." -    ) -    content = models.TextField( -        help_text="The data to add to the allow or denylist." -    ) -    comment = models.TextField( -        help_text="Optional comment on this entry.", -        null=True -    ) - -    class Meta: -        """Metaconfig for this model.""" - -        # This constraint ensures only one filterlist with the -        # same content can exist. This means that we cannot have both an allow -        # and a deny for the same item, and we cannot have duplicates of the -        # same item. -        constraints = [ -            models.UniqueConstraint(fields=['content', 'type'], name='unique_filter_list'), -        ] diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py new file mode 100644 index 00000000..95a10e42 --- /dev/null +++ b/pydis_site/apps/api/models/bot/filters.py @@ -0,0 +1,205 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.db.models import UniqueConstraint + +# Must be imported that way to avoid circular imports +from .infraction import Infraction + + +class FilterListType(models.IntegerChoices): +    """Choice between allow or deny for a list type.""" + +    ALLOW = 1 +    DENY = 0 + + +class FilterSettingsMixin(models.Model): +    """Mixin for common settings of a filters and filter lists.""" + +    dm_content = models.CharField( +        max_length=1000, +        null=True, +        help_text="The DM to send to a user triggering this filter." +    ) +    dm_embed = models.CharField( +        max_length=2000, +        help_text="The content of the DM embed", +        null=True +    ) +    infraction_type = models.CharField( +        choices=[(choices[0].upper(), choices[1]) for choices in Infraction.TYPE_CHOICES], +        max_length=10, +        null=True, +        help_text="The infraction to apply to this user." +    ) +    infraction_reason = models.CharField( +        max_length=1000, +        help_text="The reason to give for the infraction.", +        null=True +    ) +    infraction_duration = models.DurationField( +        null=True, +        help_text="The duration of the infraction. Null if permanent." +    ) + +    class Meta: +        """Metaclass for settings mixin.""" + +        abstract = True + + +class FilterList(FilterSettingsMixin): +    """Represent a list in its allow or deny form.""" + +    name = models.CharField(max_length=50, help_text="The unique name of this list.") +    list_type = models.IntegerField( +        choices=FilterListType.choices, +        help_text="Whether this list is an allowlist or denylist" +    ) +    guild_pings = ArrayField( +        models.CharField(max_length=100), +        help_text="Who to ping when this filter triggers.", +        null=False +    ) +    filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False) +    dm_pings = ArrayField( +        models.CharField(max_length=100), +        help_text="Who to ping when this filter triggers on a DM.", +        null=False +    ) +    delete_messages = models.BooleanField( +        help_text="Whether this filter should delete messages triggering it.", +        null=False +    ) +    bypass_roles = ArrayField( +        models.CharField(max_length=100), +        help_text="Roles and users who can bypass this filter.", +        null=False +    ) +    enabled = models.BooleanField( +        help_text="Whether this filter is currently enabled.", +        null=False +    ) +    send_alert = models.BooleanField( +        help_text="Whether an alert should be sent.", +    ) +    # Where a filter should apply. +    enabled_channels = ArrayField( +        models.CharField(max_length=100), +        help_text="Channels in which to run the filter even if it's disabled in the category." +    ) +    disabled_channels = ArrayField( +        models.CharField(max_length=100), +        help_text="Channels in which to not run the filter." +    ) +    enabled_categories = ArrayField( +        models.CharField(max_length=100), +        help_text="The only categories in which to run the filter." +    ) +    disabled_categories = ArrayField( +        models.CharField(max_length=100), +        help_text="Categories in which to not run the filter." +    ) + +    class Meta: +        """Constrain name and list_type unique.""" + +        constraints = ( +            UniqueConstraint(fields=("name", "list_type"), name="unique_name_type"), +        ) + +    def __str__(self) -> str: +        return f"Filter {FilterListType(self.list_type).label}list {self.name!r}" + + +class FilterBase(FilterSettingsMixin): +    """One specific trigger of a list.""" + +    content = models.CharField(max_length=100, help_text="The definition of this filter.") +    description = models.CharField( +        max_length=200, +        help_text="Why this filter has been added.", null=True +    ) +    additional_field = models.JSONField(null=True, help_text="Implementation specific field.") +    filter_list = models.ForeignKey( +        FilterList, models.CASCADE, related_name="filters", +        help_text="The filter list containing this filter." +    ) +    guild_pings = ArrayField( +        models.CharField(max_length=100), +        help_text="Who to ping when this filter triggers.", +        null=True +    ) +    filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True) +    dm_pings = ArrayField( +        models.CharField(max_length=100), +        help_text="Who to ping when this filter triggers on a DM.", +        null=True +    ) +    delete_messages = models.BooleanField( +        help_text="Whether this filter should delete messages triggering it.", +        null=True +    ) +    bypass_roles = ArrayField( +        models.CharField(max_length=100), +        help_text="Roles and users who can bypass this filter.", +        null=True +    ) +    enabled = models.BooleanField( +        help_text="Whether this filter is currently enabled.", +        null=True +    ) +    send_alert = models.BooleanField( +        help_text="Whether an alert should be sent.", +        null=True +    ) + +    # Check FilterList model for information about these properties. +    enabled_channels = ArrayField( +        models.CharField(max_length=100), +        help_text="Channels in which to run the filter even if it's disabled in the category.", +        null=True +    ) +    disabled_channels = ArrayField( +        models.CharField(max_length=100), +        help_text="Channels in which to not run the filter.", null=True +    ) +    enabled_categories = ArrayField( +        models.CharField(max_length=100), +        help_text="The only categories in which to run the filter.", +        null=True +    ) +    disabled_categories = ArrayField( +        models.CharField(max_length=100), +        help_text="Categories in which to not run the filter.", +        null=True +    ) + +    def __str__(self) -> str: +        return f"Filter {self.content!r}" + +    class Meta: +        """Metaclass for FilterBase to make it abstract model.""" + +        abstract = True + + +class Filter(FilterBase): +    """ +    The main Filter models based on `FilterBase`. + +    The purpose to have this model is to have access to the Fields of the Filter model +    and set the unique constraint based on those fields. +    """ + +    class Meta: +        """Metaclass Filter to set the unique constraint.""" + +        constraints = ( +            UniqueConstraint( +                fields=tuple( +                    [field.name for field in FilterBase._meta.fields +                     if field.name != "id" and field.name != "description"] +                ), +                name="unique_filters"), +        ) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 9228c1f4..7c1c107a 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -19,8 +19,9 @@ from .models import (      BumpedThread,      DeletedMessage,      DocumentationLink, -    FilterList,      Infraction, +    FilterList, +    Filter,      MessageDeletionContext,      Nomination,      NominationEntry, @@ -141,30 +142,216 @@ class DocumentationLinkSerializer(ModelSerializer):          fields = ('package', 'base_url', 'inventory_url') +ALWAYS_OPTIONAL_SETTINGS = ( +    'dm_content', +    'infraction_type', +    'infraction_reason', +    'infraction_duration', +) + +REQUIRED_FOR_FILTER_LIST_SETTINGS = ( +    'guild_pings', +    'filter_dm', +    'dm_pings', +    'delete_messages', +    'send_alert', +    'bypass_roles', +    'enabled', +    'enabled_channels', +    'disabled_channels', +    'enabled_categories', +    'disabled_categories', +) + +# Required fields for custom JSON representation purposes +BASE_FILTER_FIELDS = ('id', 'content', 'description', 'additional_field') +BASE_FILTERLIST_FIELDS = ('id', 'name', 'list_type') +BASE_SETTINGS_FIELDS = ( +    "bypass_roles", +    "filter_dm", +    "enabled", +    "delete_messages", +    "send_alert" +) +INFRACTION_AND_NOTIFICATION_FIELDS = ( +    "infraction_type", +    "infraction_reason", +    "infraction_duration", +    "dm_content", +    "dm_embed" +) +CHANNEL_SCOPE_FIELDS = ( +    "disabled_channels", +    "disabled_categories", +    "enabled_channels", +    "enabled_categories" +) +MENTIONS_FIELDS = ("guild_pings", "dm_pings") + +SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS + + +class FilterSerializer(ModelSerializer): +    """A class providing (de-)serialization of `Filter` instances.""" + +    def validate(self, data: dict) -> dict: +        """Perform infraction data + allow and disallowed lists validation.""" +        if ( +            data.get('infraction_reason') or data.get('infraction_duration') +        ) and not data.get('infraction_type'): +            raise ValidationError("Infraction type is required with infraction duration or reason") + +        if ( +            data.get('disabled_channels') is not None +            and data.get('enabled_channels') is not None +        ): +            channels_collection = data['disabled_channels'] + data['enabled_channels'] +            if len(channels_collection) != len(set(channels_collection)): +                raise ValidationError("Enabled and Disabled channels lists contain duplicates.") + +        if data.get('disabled_categories') is not None: +            categories_collection = data['disabled_categories'] + data['enabled_categories'] +            if len(categories_collection) != len(set(categories_collection)): +                raise ValidationError("Enabled and Disabled categories lists contain duplicates.") + +        return data + +    class Meta: +        """Metadata defined for the Django REST Framework.""" + +        model = Filter +        fields = ( +            'id', 'content', 'description', 'additional_field', 'filter_list' +        ) + SETTINGS_FIELDS +        extra_kwargs = { +            field: {'required': False, 'allow_null': True} for field in SETTINGS_FIELDS +        } | { +            'infraction_reason': {'allow_blank': True, 'allow_null': True, 'required': False}, +            'enabled_channels': {'allow_empty': True, 'allow_null': True, 'required': False}, +            'disabled_channels': {'allow_empty': True, 'allow_null': True, 'required': False}, +            'disabled_categories': {'allow_empty': True, 'allow_null': True, 'required': False}, +        } + +    def to_representation(self, instance: Filter) -> dict: +        """ +        Provides a custom JSON representation to the Filter Serializers. + +        This representation restructures how the Filter is represented. +        It groups the Infraction, Channel and Mention related fields into their own separated group. + +        Furthermore, it puts the fields that meant to represent Filter settings, +        into a sub-field called `settings`. +        """ +        schema_settings = { +            "settings": +                {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} +                | { +                    "infraction_and_notification": +                        {name: getattr(instance, name) +                         for name in INFRACTION_AND_NOTIFICATION_FIELDS} +                } | { +                    "channel_scope": +                        {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS} +                } | { +                    "mentions": +                        { +                            schema_field_name: getattr(instance, schema_field_name) +                            for schema_field_name in MENTIONS_FIELDS +                        } +                } +        } +        schema_base = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS} | \ +                      {"filter_list": instance.filter_list.id} + +        return schema_base | schema_settings + +  class FilterListSerializer(ModelSerializer):      """A class providing (de-)serialization of `FilterList` instances.""" +    filters = FilterSerializer(many=True, read_only=True) + +    def validate(self, data: dict) -> dict: +        """Perform infraction data + allow and disallowed lists validation.""" +        if ( +            data.get('infraction_reason') or data.get('infraction_duration') +        ) and not data.get('infraction_type'): +            raise ValidationError("Infraction type is required with infraction duration or reason") + +        if ( +            data.get('disabled_channels') is not None +            and data.get('enabled_channels') is not None +        ): +            channels_collection = data['disabled_channels'] + data['enabled_channels'] +            if len(channels_collection) != len(set(channels_collection)): +                raise ValidationError("Enabled and Disabled channels lists contain duplicates.") + +        if data.get('disabled_categories') is not None: +            categories_collection = data['disabled_categories'] +            if len(categories_collection) != len(set(categories_collection)): +                raise ValidationError("Disabled categories lists contain duplicates.") + +        return data +      class Meta:          """Metadata defined for the Django REST Framework."""          model = FilterList -        fields = ('id', 'created_at', 'updated_at', 'type', 'allowed', 'content', 'comment') - -        # This validator ensures only one filterlist with the -        # same content can exist. This means that we cannot have both an allow -        # and a deny for the same item, and we cannot have duplicates of the -        # same item. +        fields = ('id', 'name', 'list_type', 'filters') + SETTINGS_FIELDS +        extra_kwargs = { +            field: {'required': False, 'allow_null': True} for field in ALWAYS_OPTIONAL_SETTINGS +        } | { +            'infraction_reason': {'allow_blank': True, 'allow_null': True, 'required': False}, +            'enabled_channels': {'allow_empty': True}, +            'disabled_channels': {'allow_empty': True}, +            'disabled_categories': {'allow_empty': True}, +        } + +        # Ensure that we can only have one filter list with the same name and field          validators = [              UniqueTogetherValidator(                  queryset=FilterList.objects.all(), -                fields=['content', 'type'], +                fields=('name', 'list_type'),                  message=( -                    "A filterlist for this item already exists. " -                    "Please note that you cannot add the same item to both allow and deny." +                    "A filterlist with the same name and type already exist."                  )              ),          ] +    def to_representation(self, instance: FilterList) -> dict: +        """ +        Provides a custom JSON representation to the FilterList Serializers. + +        This representation restructures how the Filter is represented. +        It groups the Infraction, Channel and Mention related fields into their own separated group. + +        Furthermore, it puts the fields that meant to represent FilterList settings, +        into a sub-field called `settings`. +        """ +        # Fetches the relating filters +        filters = [ +            FilterSerializer(many=False).to_representation( +                instance=item +            ) for item in Filter.objects.filter( +                filter_list=instance.id +            ) +        ] +        schema_base = {name: getattr(instance, name) for name in BASE_FILTERLIST_FIELDS} \ +            | {"filters": filters} +        schema_settings_base = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} +        schema_settings_categories = { +            "infraction_and_notification": +            {name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS}} \ +            | { +            "channel_scope": +            {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}} | { +            "mentions": { +                schema_field_name: getattr(instance, schema_field_name) +                for schema_field_name in MENTIONS_FIELDS +            } +        } +        return schema_base | {"settings": schema_settings_base | schema_settings_categories} +  class InfractionSerializer(ModelSerializer):      """A class providing (de-)serialization of `Infraction` instances.""" @@ -203,7 +390,7 @@ class InfractionSerializer(ModelSerializer):          if hidden and infr_type in ('superstar', 'warning', 'voice_ban', 'voice_mute'):              raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']}) -        if not hidden and infr_type in ('note', ): +        if not hidden and infr_type in ('note',):              raise ValidationError({'hidden': [f'{infr_type} infractions must be hidden.']})          return attrs diff --git a/pydis_site/apps/api/tests/test_filterlists.py b/pydis_site/apps/api/tests/test_filterlists.py deleted file mode 100644 index 9959617e..00000000 --- a/pydis_site/apps/api/tests/test_filterlists.py +++ /dev/null @@ -1,122 +0,0 @@ -from django.urls import reverse - -from pydis_site.apps.api.models import FilterList -from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase - -URL = reverse('api:bot:filterlist-list') -JPEG_ALLOWLIST = { -    "type": 'FILE_FORMAT', -    "allowed": True, -    "content": ".jpeg", -} -PNG_ALLOWLIST = { -    "type": 'FILE_FORMAT', -    "allowed": True, -    "content": ".png", -} - - -class UnauthenticatedTests(AuthenticatedAPITestCase): -    def setUp(self): -        super().setUp() -        self.client.force_authenticate(user=None) - -    def test_cannot_read_allowedlist_list(self): -        response = self.client.get(URL) - -        self.assertEqual(response.status_code, 401) - - -class EmptyDatabaseTests(AuthenticatedAPITestCase): -    @classmethod -    def setUpTestData(cls): -        FilterList.objects.all().delete() - -    def test_returns_empty_object(self): -        response = self.client.get(URL) - -        self.assertEqual(response.status_code, 200) -        self.assertEqual(response.json(), []) - - -class FetchTests(AuthenticatedAPITestCase): -    @classmethod -    def setUpTestData(cls): -        FilterList.objects.all().delete() -        cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST) -        cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST) - -    def test_returns_name_in_list(self): -        response = self.client.get(URL) - -        self.assertEqual(response.status_code, 200) -        self.assertEqual(response.json()[0]["content"], self.jpeg_format.content) -        self.assertEqual(response.json()[1]["content"], self.png_format.content) - -    def test_returns_single_item_by_id(self): -        response = self.client.get(f'{URL}/{self.jpeg_format.id}') - -        self.assertEqual(response.status_code, 200) -        self.assertEqual(response.json().get("content"), self.jpeg_format.content) - -    def test_returns_filter_list_types(self): -        response = self.client.get(f'{URL}/get-types') - -        self.assertEqual(response.status_code, 200) -        for api_type, model_type in zip(response.json(), FilterList.FilterListType.choices): -            self.assertEqual(api_type[0], model_type[0]) -            self.assertEqual(api_type[1], model_type[1]) - - -class CreationTests(AuthenticatedAPITestCase): -    @classmethod -    def setUpTestData(cls): -        FilterList.objects.all().delete() - -    def test_returns_400_for_missing_params(self): -        no_type_json = { -            "allowed": True, -            "content": ".jpeg" -        } -        no_allowed_json = { -            "type": "FILE_FORMAT", -            "content": ".jpeg" -        } -        no_content_json = { -            "allowed": True, -            "type": "FILE_FORMAT" -        } -        cases = [{}, no_type_json, no_allowed_json, no_content_json] - -        for case in cases: -            with self.subTest(case=case): -                response = self.client.post(URL, data=case) -                self.assertEqual(response.status_code, 400) - -    def test_returns_201_for_successful_creation(self): -        response = self.client.post(URL, data=JPEG_ALLOWLIST) -        self.assertEqual(response.status_code, 201) - -    def test_returns_400_for_duplicate_creation(self): -        self.client.post(URL, data=JPEG_ALLOWLIST) -        response = self.client.post(URL, data=JPEG_ALLOWLIST) -        self.assertEqual(response.status_code, 400) - - -class DeletionTests(AuthenticatedAPITestCase): -    @classmethod -    def setUpTestData(cls): -        FilterList.objects.all().delete() -        cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST) -        cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST) - -    def test_deleting_unknown_id_returns_404(self): -        response = self.client.delete(f"{URL}/200") -        self.assertEqual(response.status_code, 404) - -    def test_deleting_known_id_returns_204(self): -        response = self.client.delete(f"{URL}/{self.jpeg_format.id}") -        self.assertEqual(response.status_code, 204) - -        response = self.client.get(f"{URL}/{self.jpeg_format.id}") -        self.assertNotIn(self.png_format.content, response.json()) diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py new file mode 100644 index 00000000..5f40c6f9 --- /dev/null +++ b/pydis_site/apps/api/tests/test_filters.py @@ -0,0 +1,298 @@ +import contextlib +from dataclasses import dataclass +from typing import Any, Dict, Tuple, Type + +from django.db.models import Model +from django_hosts import reverse + +from pydis_site.apps.api.models.bot.filters import (  # noqa: I101 - Preserving the filter order +    FilterList, +    FilterSettings, +    FilterAction, +    ChannelRange, +    Filter, +    FilterOverride +) +from pydis_site.apps.api.tests.base import APISubdomainTestCase + + +@dataclass() +class TestSequence: +    model: Type[Model] +    route: str +    object: Dict[str, Any] +    ignored_fields: Tuple[str] = () + +    def url(self, detail: bool = False) -> str: +        return reverse(f'bot:{self.route}-{"detail" if detail else "list"}', host='api') + + +FK_FIELDS: Dict[Type[Model], Tuple[str]] = { +    FilterList: ("default_settings",), +    FilterSettings: ("default_action", "default_range"), +    FilterAction: (), +    ChannelRange: (), +    Filter: ("filter_list",), +    FilterOverride: ("filter_action", "filter_range") +} + + +def get_test_sequences() -> Dict[str, TestSequence]: +    return { +        "filter_list": TestSequence( +            FilterList, +            "filterlist", +            { +                "name": "testname", +                "list_type": 0, +                "default_settings": FilterSettings( +                    ping_type=[], +                    filter_dm=False, +                    dm_ping_type=[], +                    delete_messages=False, +                    bypass_roles=[], +                    enabled=False, +                    default_action=FilterAction( +                        dm_content=None, +                        infraction_type=None, +                        infraction_reason="", +                        infraction_duration=None +                    ), +                    default_range=ChannelRange( +                        disallowed_channels=[], +                        disallowed_categories=[], +                        allowed_channels=[], +                        allowed_categories=[], +                        default=False +                    ) +                ) +            }, +            ignored_fields=("filters",) +        ), +        "filter_settings": TestSequence( +            FilterSettings, +            "filtersettings", +            { +                "ping_type": ["onduty"], +                "filter_dm": True, +                "dm_ping_type": ["123456"], +                "delete_messages": True, +                "bypass_roles": [123456], +                "enabled": True, +                "default_action": FilterAction( +                    dm_content=None, +                    infraction_type=None, +                    infraction_reason="", +                    infraction_duration=None +                ), +                "default_range": ChannelRange( +                    disallowed_channels=[], +                    disallowed_categories=[], +                    allowed_channels=[], +                    allowed_categories=[], +                    default=False +                ) +            } +        ), +        "filter_action": TestSequence( +            FilterAction, +            "filteraction", +            { +                "dm_content": "This is a DM message.", +                "infraction_type": "Mute", +                "infraction_reason": "Too long beard", +                "infraction_duration": "1 02:03:00" +            } +        ), +        "channel_range": TestSequence( +            ChannelRange, +            "channelrange", +            { +                "disallowed_channels": [1234], +                "disallowed_categories": [5678], +                "allowed_channels": [9101], +                "allowed_categories": [1121], +                "default": True +            } +        ), +        "filter": TestSequence( +            Filter, +            "filter", +            { +                "content": "bad word", +                "description": "This is a really bad word.", +                "additional_field": None, +                "override": None, +                "filter_list": FilterList( +                    name="testname", +                    list_type=0, +                    default_settings=FilterSettings( +                        ping_type=[], +                        filter_dm=False, +                        dm_ping_type=[], +                        delete_messages=False, +                        bypass_roles=[], +                        enabled=False, +                        default_action=FilterAction( +                            dm_content=None, +                            infraction_type=None, +                            infraction_reason="", +                            infraction_duration=None +                        ), +                        default_range=ChannelRange( +                            disallowed_channels=[], +                            disallowed_categories=[], +                            allowed_channels=[], +                            allowed_categories=[], +                            default=False +                        ) +                    ) +                ) +            } +        ), +        "filter_override": TestSequence( +            FilterOverride, +            "filteroverride", +            { +                "ping_type": ["everyone"], +                "filter_dm": False, +                "dm_ping_type": ["here"], +                "delete_messages": False, +                "bypass_roles": [9876], +                "enabled": True, +                "filter_action": None, +                "filter_range": None +            } +        ) +    } + + +def save_nested_objects(object_: Model, save_root: bool = True) -> None: +    for field in FK_FIELDS[object_.__class__]: +        value = getattr(object_, field) + +        if value is not None: +            save_nested_objects(value) + +    if save_root: +        object_.save() + + +def clean_test_json(json: dict) -> dict: +    for key, value in json.items(): +        if isinstance(value, Model): +            json[key] = value.id + +    return json + + +def clean_api_json(json: dict, sequence: TestSequence) -> dict: +    for field in sequence.ignored_fields + ("id",): +        with contextlib.suppress(KeyError): +            del json[field] + +    return json + + +class GenericFilterTest(APISubdomainTestCase): +    def test_cannot_read_unauthenticated(self) -> None: +        for name, sequence in get_test_sequences().items(): +            with self.subTest(name=name): +                self.client.force_authenticate(user=None) + +                response = self.client.get(sequence.url()) +                self.assertEqual(response.status_code, 401) + +    def test_empty_database(self) -> None: +        for name, sequence in get_test_sequences().items(): +            with self.subTest(name=name): +                sequence.model.objects.all().delete() + +                response = self.client.get(sequence.url()) +                self.assertEqual(response.status_code, 200) +                self.assertEqual(response.json(), []) + +    def test_fetch(self) -> None: +        for name, sequence in get_test_sequences().items(): +            with self.subTest(name=name): +                sequence.model.objects.all().delete() + +                save_nested_objects(sequence.model(**sequence.object)) + +                response = self.client.get(sequence.url()) +                self.assertDictEqual( +                    clean_test_json(sequence.object), +                    clean_api_json(response.json()[0], sequence) +                ) + +    def test_fetch_by_id(self) -> None: +        for name, sequence in get_test_sequences().items(): +            with self.subTest(name=name): +                sequence.model.objects.all().delete() + +                saved = sequence.model(**sequence.object) +                save_nested_objects(saved) + +                response = self.client.get(f"{sequence.url()}/{saved.id}") +                self.assertDictEqual( +                    clean_test_json(sequence.object), +                    clean_api_json(response.json(), sequence) +                ) + +    def test_fetch_non_existing(self) -> None: +        for name, sequence in get_test_sequences().items(): +            with self.subTest(name=name): +                sequence.model.objects.all().delete() + +                response = self.client.get(f"{sequence.url()}/42") +                self.assertEqual(response.status_code, 404) +                self.assertDictEqual(response.json(), {'detail': 'Not found.'}) + +    def test_creation(self) -> None: +        for name, sequence in get_test_sequences().items(): +            with self.subTest(name=name): +                sequence.model.objects.all().delete() + +                save_nested_objects(sequence.model(**sequence.object), False) +                data = clean_test_json(sequence.object.copy()) +                response = self.client.post(sequence.url(), data=data) + +                self.assertEqual(response.status_code, 201) +                self.assertDictEqual( +                    clean_api_json(response.json(), sequence), +                    clean_test_json(sequence.object) +                ) + +    def test_creation_missing_field(self) -> None: +        for name, sequence in get_test_sequences().items(): +            with self.subTest(name=name): +                save_nested_objects(sequence.model(**sequence.object), False) +                data = clean_test_json(sequence.object.copy()) + +                for field in sequence.model._meta.get_fields(): +                    with self.subTest(field=field): +                        if field.null or field.name in sequence.ignored_fields + ("id",): +                            continue + +                        test_data = data.copy() +                        del test_data[field.name] + +                        response = self.client.post(sequence.url(), data=test_data) +                        self.assertEqual(response.status_code, 400) + +    def test_deletion(self) -> None: +        for name, sequence in get_test_sequences().items(): +            with self.subTest(name=name): +                saved = sequence.model(**sequence.object) +                save_nested_objects(saved) + +                response = self.client.delete(f"{sequence.url()}/{saved.id}") +                self.assertEqual(response.status_code, 204) + +    def test_deletion_non_existing(self) -> None: +        for name, sequence in get_test_sequences().items(): +            with self.subTest(name=name): +                sequence.model.objects.all().delete() + +                response = self.client.delete(f"{sequence.url()}/42") +                self.assertEqual(response.status_code, 404) diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index c07d59cd..b9b14a84 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -6,6 +6,9 @@ from django.test import SimpleTestCase, TestCase  from pydis_site.apps.api.models import (      DeletedMessage,      DocumentationLink, +    Filter, +    FilterList, +    FilterSettings,      Infraction,      MessageDeletionContext,      Nomination, @@ -104,6 +107,17 @@ class StringDunderMethodTests(SimpleTestCase):              DocumentationLink(                  'test', 'http://example.com', 'http://example.com'              ), +            FilterList( +                name="forbidden_duckies", +                list_type=0, +                default_settings=FilterSettings() +            ), +            Filter( +                content="ducky_nsfw", +                description="This ducky is totally inappropriate!", +                additional_field=None, +                override=None +            ),              OffensiveMessage(                  id=602951077675139072,                  channel_id=291284109232308226, diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index 2757f176..f872ba92 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -10,6 +10,7 @@ from .viewsets import (      DeletedMessageViewSet,      DocumentationLinkViewSet,      FilterListViewSet, +    FilterViewSet,      InfractionViewSet,      NominationViewSet,      OffTopicChannelNameViewSet, @@ -22,6 +23,10 @@ from .viewsets import (  # https://www.django-rest-framework.org/api-guide/routers/#defaultrouter  bot_router = DefaultRouter(trailing_slash=False)  bot_router.register( +    'filter/filter_lists', +    FilterListViewSet +) +bot_router.register(      "aoc-account-links",      AocAccountLinkViewSet  ) @@ -30,6 +35,10 @@ bot_router.register(      AocCompletionistBlockViewSet  )  bot_router.register( +    'filter/filters', +    FilterViewSet +) +bot_router.register(      'bot-settings',      BotSettingViewSet  ) diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index ec52416a..1dae9be1 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -6,6 +6,8 @@ from .bot import (      DocumentationLinkViewSet,      FilterListViewSet,      InfractionViewSet, +    FilterListViewSet, +    FilterViewSet,      NominationViewSet,      OffensiveMessageViewSet,      AocAccountLinkViewSet, diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py index 262aa59f..33b65009 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -1,5 +1,8 @@  # flake8: noqa -from .filter_list import FilterListViewSet +from .filters import ( +    FilterListViewSet, +    FilterViewSet +)  from .bot_setting import BotSettingViewSet  from .bumped_thread import BumpedThreadViewSet  from .deleted_message import DeletedMessageViewSet diff --git a/pydis_site/apps/api/viewsets/bot/filter_list.py b/pydis_site/apps/api/viewsets/bot/filter_list.py deleted file mode 100644 index 4b05acee..00000000 --- a/pydis_site/apps/api/viewsets/bot/filter_list.py +++ /dev/null @@ -1,98 +0,0 @@ -from rest_framework.decorators import action -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet - -from pydis_site.apps.api.models.bot.filter_list import FilterList -from pydis_site.apps.api.serializers import FilterListSerializer - - -class FilterListViewSet(ModelViewSet): -    """ -    View providing CRUD operations on items allowed or denied by our bot. - -    ## Routes -    ### GET /bot/filter-lists -    Returns all filterlist items in the database. - -    #### Response format -    >>> [ -    ...     { -    ...         'id': "2309268224", -    ...         'created_at': "01-01-2020 ...", -    ...         'updated_at': "01-01-2020 ...", -    ...         'type': "file_format", -    ...         'allowed': 'true', -    ...         'content': ".jpeg", -    ...         'comment': "Popular image format.", -    ...     }, -    ...     ... -    ... ] - -    #### Status codes -    - 200: returned on success -    - 401: returned if unauthenticated - -    ### GET /bot/filter-lists/<id:int> -    Returns a specific FilterList item from the database. - -    #### Response format -    >>> { -    ...     'id': "2309268224", -    ...     'created_at': "01-01-2020 ...", -    ...     'updated_at': "01-01-2020 ...", -    ...     'type': "file_format", -    ...     'allowed': 'true', -    ...     'content': ".jpeg", -    ...     'comment': "Popular image format.", -    ... } - -    #### Status codes -    - 200: returned on success -    - 404: returned if the id was not found. - -    ### GET /bot/filter-lists/get-types -    Returns a list of valid list types that can be used in POST requests. - -    #### Response format -    >>> [ -    ...     ["GUILD_INVITE","Guild Invite"], -    ...     ["FILE_FORMAT","File Format"], -    ...     ["DOMAIN_NAME","Domain Name"], -    ...     ["FILTER_TOKEN","Filter Token"], -    ...     ["REDIRECT", "Redirect"] -    ... ] - -    #### Status codes -    - 200: returned on success - -    ### POST /bot/filter-lists -    Adds a single FilterList item to the database. - -    #### Request body -    >>> { -    ...    'type': str, -    ...    'allowed': bool, -    ...    'content': str, -    ...    'comment': Optional[str], -    ... } - -    #### Status codes -    - 201: returned on success -    - 400: if one of the given fields is invalid - -    ### DELETE /bot/filter-lists/<id:int> -    Deletes the FilterList item with the given `id`. - -    #### Status codes -    - 204: returned on success -    - 404: if a tag with the given `id` does not exist -    """ - -    serializer_class = FilterListSerializer -    queryset = FilterList.objects.all() - -    @action(detail=False, url_path='get-types', methods=["get"]) -    def get_types(self, _: Request) -> Response: -        """Get a list of all the types of FilterLists we support.""" -        return Response(FilterList.FilterListType.choices) diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py new file mode 100644 index 00000000..dd9a7d87 --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -0,0 +1,303 @@ +from rest_framework.viewsets import ModelViewSet + +from pydis_site.apps.api.models.bot.filters import (  # noqa: I101 - Preserving the filter order +    FilterList, +    Filter +) +from pydis_site.apps.api.serializers import (  # noqa: I101 - Preserving the filter order +    FilterListSerializer, +    FilterSerializer, +) + + +class FilterListViewSet(ModelViewSet): +    """ +    View providing GET/DELETE on lists of items allowed or denied by our bot. + +    ## Routes +    ### GET /bot/filter/filter_lists +    Returns all FilterList items in the database. + +    #### Response format +    >>> [ +    ...     { +    ...         "id": 1, +    ...         "name": "invites", +    ...         "list_type": 1, +    ...         "filters": [ +    ...             { +    ...                 "id": 1, +    ...                 "content": "267624335836053506", +    ...                 "description": "Python Discord", +    ...                 "additional_field": None, +    ...                 "filter_list": 1 +    ...                 "settings": { +    ...                        "bypass_roles": None +    ...                        "filter_dm": None, +    ...                        "enabled": None +    ...                        "send_alert": True, +    ...                        "delete_messages": None +    ...                        "infraction_and_notification": { +    ...                            "infraction_type": None, +    ...                            "infraction_reason": "", +    ...                            "infraction_duration": None +    ...                            "dm_content": None, +    ...                            "dm_embed": None +    ...                        }, +    ...                        "channel_scope": { +    ...                            "disabled_channels": None, +    ...                            "disabled_categories": None, +    ...                            "enabled_channels": None +    ...                        } +    ...                        "mentions": { +    ...                            "ping_type": None +    ...                            "dm_ping_type": None +    ...                         } +    ...                    } +    ... +    ...             }, +    ...             ... +    ...         ], +    ...            "settings": { +    ...              "bypass_roles": [ +    ...                  "staff" +    ...              ], +    ...              "filter_dm": True, +    ...              "enabled": True +    ...              "delete_messages": True, +    ...              "send_alert": True +    ...              "infraction_and_notification": { +    ...                   "infraction_type": "", +    ...                   "infraction_reason": "", +    ...                   "infraction_duration": "0.0", +    ...                   "dm_content": "", +    ...                   "dm_embed": "" +    ...               } +    ...               "channel_scope": { +    ...                 "disabled_channels": [], +    ...                 "disabled_categories": [], +    ...                 "enabled_channels": [] +    ...               } +    ...               "mentions": { +    ...                 "ping_type": [ +    ...                     "onduty" +    ...                 ] +    ...                 "dm_ping_type": [] +    ...                } +    ...           }, +    ...     ... +    ... ] + +    #### Status codes +    - 200: returned on success +    - 401: returned if unauthenticated + +    ### GET /bot/filter/filter_lists/<id:int> +    Returns a specific FilterList item from the database. + +    #### Response format +    >>> { +    ...         "id": 1, +    ...         "name": "invites", +    ...         "list_type": 1, +    ...         "filters": [ +    ...             { +    ...                 "id": 1, +    ...                 "filter_list": 1 +    ...                 "content": "267624335836053506", +    ...                 "description": "Python Discord", +    ...                 "additional_field": None, +    ...                 "settings": { +    ...                        "bypass_roles": None +    ...                        "filter_dm": None, +    ...                        "enabled": None +    ...                        "delete_messages": None, +    ...                        "send_alert": None +    ...                        "infraction_and_notification": { +    ...                            "infraction_type": None, +    ...                            "infraction_reason": "", +    ...                            "infraction_duration": None +    ...                            "dm_content": None, +    ...                            "dm_embed": None +    ...                        }, +    ...                        "channel_scope": { +    ...                            "disabled_channels": None, +    ...                            "disabled_categories": None, +    ...                            "enabled_channels": None +    ...                        } +    ...                        "mentions": { +    ...                            "ping_type": None +    ...                            "dm_ping_type": None +    ...                         } +    ...                    } +    ... +    ...             }, +    ...             ... +    ...         ], +    ...            "settings": { +    ...              "bypass_roles": [ +    ...                  "staff" +    ...              ], +    ...              "filter_dm": True, +    ...              "enabled": True +    ...              "delete_messages": True +    ...              "send_alert": True +    ...              "infraction_and_notification": { +    ...                   "infraction_type": "", +    ...                   "infraction_reason": "", +    ...                   "infraction_duration": "0.0", +    ...                   "dm_content": "", +    ...                   "dm_embed": "" +    ...               } +    ...               "channel_scope": { +    ...                 "disabled_channels": [], +    ...                 "disabled_categories": [], +    ...                 "enabled_channels": [] +    ...                } +    ...               "mentions": { +    ...                 "ping_type": [ +    ...                     "onduty" +    ...                 ] +    ...                 "dm_ping_type": [] +    ...                } +    ... } + +    #### Status codes +    - 200: returned on success +    - 404: returned if the id was not found. + +    ### DELETE /bot/filter/filter_lists/<id:int> +    Deletes the FilterList item with the given `id`. + +    #### Status codes +    - 204: returned on success +    - 404: if a tag with the given `id` does not exist +    """ + +    serializer_class = FilterListSerializer +    queryset = FilterList.objects.all() + + +class FilterViewSet(ModelViewSet): +    """ +    View providing CRUD operations on items allowed or denied by our bot. + +    ## Routes +    ### GET /bot/filter/filters +    Returns all Filter items in the database. + +    #### Response format +    >>> [ +    ...     { +    ...                 "id": 1, +    ...                 "filter_list": 1 +    ...                 "content": "267624335836053506", +    ...                 "description": "Python Discord", +    ...                 "additional_field": None, +    ...                 "settings": { +    ...                        "bypass_roles": None +    ...                        "filter_dm": None, +    ...                        "enabled": None +    ...                        "delete_messages": True, +    ...                        "send_alert": True +    ...                        "infraction": { +    ...                            "infraction_type": None, +    ...                            "infraction_reason": None, +    ...                            "infraction_duration": None +    ...                        }, +    ...                        "channel_scope": { +    ...                          "disabled_channels": None, +    ...                          "disabled_categories": None, +    ...                          "enabled_channels": None +    ...                        } +    ...                        "mentions": { +    ...                          "ping_type": None, +    ...                          "dm_ping_type": None +    ...                       } +    ...                    } +    ...     }, +    ...     ... +    ... ] + +    #### Status codes +    - 200: returned on success +    - 401: returned if unauthenticated + +    ### GET /bot/filter/filters/<id:int> +    Returns a specific Filter item from the database. + +    #### Response format +    >>> { +    ...                 "id": 1, +    ...                 "filter_list": 1 +    ...                 "content": "267624335836053506", +    ...                 "description": "Python Discord", +    ...                 "additional_field": None, +    ...                 "settings": { +    ...                        "bypass_roles": None +    ...                        "filter_dm": None, +    ...                        "enabled": None +    ...                        "delete_messages": True, +    ...                        "send_alert": True +    ...                        "infraction": { +    ...                            "infraction_type": None, +    ...                            "infraction_reason": None, +    ...                            "infraction_duration": None +    ...                        }, +    ...                        "channel_scope": { +    ...                          "disabled_channels": None, +    ...                          "disabled_categories": None, +    ...                          "enabled_channels": None, +    ...                        } +    ...                       "mentions": { +    ...                         "ping_type": None +    ...                         "dm_ping_type": None +    ...                       } +    ...                    } +    ... } + +    #### Status codes +    - 200: returned on success +    - 404: returned if the id was not found. + +    ### POST /bot/filter/filters +    Adds a single Filter item to the database. + +    #### Request body +    >>> { +    ...     "content": "267624335836053506", +    ...     "description": "Python Discord", +    ...     "additional_field": None, +    ...     "override": 1 +    ... } + +    #### Status codes +    - 201: returned on success +    - 400: if one of the given fields is invalid + +    ### PATCH /bot/filter/filters/<id:int> +    Updates a specific Filter item from the database. + +    #### Response format +    >>> { +    ...     "id": 1, +    ...     "content": "267624335836053506", +    ...     "description": "Python Discord", +    ...     "additional_field": None, +    ...     "override": 1 +    ... } + +    #### Status codes +    - 200: returned on success +    - 400: if one of the given fields is invalid + +    ### DELETE /bot/filter/filters/<id:int> +    Deletes the Filter item with the given `id`. + +    #### Status codes +    - 204: returned on success +    - 404: if a tag with the given `id` does not exist +    """ + +    serializer_class = FilterSerializer +    queryset = Filter.objects.all() | 
