diff options
19 files changed, 1361 insertions, 279 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/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py new file mode 100644 index 00000000..f56c29f8 --- /dev/null +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -0,0 +1,145 @@ +# Modified migration file to migrate existing filters to the new one +from datetime import timedelta + +import django.contrib.postgres.fields +from django.apps.registry import Apps +from django.db import migrations, models +import django.db.models.deletion +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import pydis_site.apps.api.models.bot.filters + +OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY')) + + +def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: +    filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter") +    filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList") +    filter_list_old = apps.get_model("api", "FilterListOld") + +    for name, type_ in OLD_LIST_NAMES: +        objects = filter_list_old.objects.filter(type=name) +        if name == "DOMAIN_NAME": +            dm_content = "Your URL has been removed because it matched a blacklisted domain: {match}" +        elif name == "GUILD_INVITE": +            dm_content = "Per Rule 6, your invite link has been removed. " \ +                         "Our server rules can be found here: https://pythondiscord.com/pages/rules" +        else: +            dm_content = "" + +        list_ = filter_list.objects.create( +            name=name.lower(), +            list_type=1 if type_ == "ALLOW" else 0, +            ping_type=(["onduty"] if name != "FILE_FORMAT" else []), +            filter_dm=True, +            dm_ping_type=[], +            delete_messages=(True if name != "FILTER_TOKEN" else False), +            bypass_roles=["staff"], +            enabled=True, +            dm_content=dm_content, +            infraction_type="", +            infraction_reason="", +            infraction_duration=timedelta(seconds=0), +            disallowed_channels=[], +            disallowed_categories=[], +            allowed_channels=[], +            allowed_categories=[] +        ) + +        for object_ in objects: +            new_object = filter_.objects.create( +                content=object_.content, +                filter_list=list_, +                description=object_.comment or "<no description provided>", +                additional_field=None, +                ping_type=None, +                filter_dm=None, +                dm_ping_type=None, +                delete_messages=None, +                bypass_roles=None, +                enabled=None, +                dm_content=None, +                infraction_type=None, +                infraction_reason="", +                infraction_duration=None, +                disallowed_channels=None, +                disallowed_categories=None, +                allowed_channels=None, +                allowed_categories=None +            ) +            new_object.save() + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0069_documentationlink_validators'), +    ] + +    operations = [ +        migrations.RenameModel( +            old_name='FilterList', +            new_name='FilterListOld' +        ), +        migrations.CreateModel( +            name='Filter', +            fields=[ +                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), +                ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), +                ('description', models.CharField(help_text='Why this filter has been added.', max_length=200)), +                ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)), +                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), +                ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), +                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), +                ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)), +                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field], null=True)), +                ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)), +                ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), +                ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), +                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), +                ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), +                ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), +                ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), +                ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), +                ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), +            ], +        ), +        migrations.CreateModel( +            name='FilterList', +            fields=[ +                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), +                ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), +                ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')), +                ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), +                ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')), +                ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), +                ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')), +                ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field])), +                ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')), +                ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), +                ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), +                ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), +                ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), +                ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), +                ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), +                ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), +                ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), +            ], +        ), +        migrations.AddField( +            model_name='filter', +            name='filter_list', +            field=models.ForeignKey(help_text='The filter list containing this filter.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='api.FilterList'), +        ), +        migrations.AddConstraint( +            model_name='filterlist', +            constraint=models.UniqueConstraint(fields=('name', 'list_type'), name='unique_name_type'), +        ), +        migrations.RunPython( +            code=forward,  # Core of the migration +            reverse_code=lambda *_: None +        ), +        migrations.DeleteModel( +            name='FilterListOld' +        ) +    ] diff --git a/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py b/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py new file mode 100644 index 00000000..ae41ac71 --- /dev/null +++ b/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.14 on 2021-10-17 08:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0073_otn_allow_GT_and_LT'), +        ('api', '0070_new_filter_schema'), +    ] + +    operations = [ +    ] diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py new file mode 100644 index 00000000..56cbdedb --- /dev/null +++ b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py @@ -0,0 +1,95 @@ +# Generated by Django 3.0.14 on 2021-12-11 23:14 +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: +    FilterList = apps.get_model("api", "FilterList") +    change_map = { +        "filter_token": "tokens", +        "domain_name": "domains", +        "guild_invite": "invites", +        "file_format": "extensions" +    } +    for filter_list in FilterList.objects.all(): +        if change_map.get(filter_list.name): +            filter_list.name = change_map.get(filter_list.name) +            filter_list.save() +    redirects = FilterList( +        name="redirects", +        ping_type=[], +        dm_ping_type=[], +        enabled_channels=[], +        disabled_channels=[], +        disabled_categories=[], +        list_type=0, +        filter_dm=True, +        delete_messages=False, +        bypass_roles=["staff"], +        enabled=True +    ) +    redirects.save() + + +def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: +    FilterList = apps.get_model("api", "FilterList") +    change_map = { +        "tokens": "filter_token", +        "domains": "domain_name", +        "invites": "guild_invite", +        "formats": "file_format" +    } +    for filter_list in FilterList.objects.all(): +        if change_map.get(filter_list.name): +            filter_list.name = change_map.get(filter_list.name) +            filter_list.save() +    FilterList.objects.filter(name="redirects").delete() + + +class Migration(migrations.Migration): +    dependencies = [ +        ('api', '0074_merge_20211017_0822'), +    ] + +    operations = [ +        migrations.RenameField( +            model_name='filter', +            old_name='allowed_categories', +            new_name='disabled_categories', +        ), +        migrations.RenameField( +            model_name='filter', +            old_name='allowed_channels', +            new_name='disabled_channels', +        ), +        migrations.RenameField( +            model_name='filter', +            old_name='disallowed_channels', +            new_name='enabled_channels', +        ), +        migrations.RenameField( +            model_name='filterlist', +            old_name='allowed_categories', +            new_name='disabled_categories', +        ), +        migrations.RenameField( +            model_name='filterlist', +            old_name='allowed_channels', +            new_name='disabled_channels', +        ), +        migrations.RenameField( +            model_name='filterlist', +            old_name='disallowed_channels', +            new_name='enabled_channels', +        ), +        migrations.RemoveField( +            model_name='filterlist', +            name='disallowed_categories', +        ), +        migrations.RemoveField( +            model_name='filter', +            name='disallowed_categories', +        ), +        migrations.RunPython(migrate_filterlist, unmigrate_filterlist) +    ] diff --git a/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py b/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py new file mode 100644 index 00000000..7fe559f5 --- /dev/null +++ b/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.14 on 2021-12-18 22:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0075_prepare_filter_and_filterlist_for_new_filter_schema'), +        ('api', '0077_use_generic_jsonfield'), +    ] + +    operations = [ +    ] diff --git a/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py new file mode 100644 index 00000000..c6299cb9 --- /dev/null +++ b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py @@ -0,0 +1,57 @@ +# Generated by Django 3.1.14 on 2021-12-19 23:05 +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: +    FilterList = apps.get_model("api", "FilterList") +    change_map = { +        "tokens": True, +        "domains": True, +        "invites": True, +        "extensions": False, +        "redirects": False +    } +    for filter_list in FilterList.objects.all(): +        filter_list.send_alert = change_map.get(filter_list.name) +        filter_list.dm_embed = "" +        filter_list.save() + + +def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: +    FilterList = apps.get_model("api", "FilterList") +    for filter_list in FilterList.objects.all(): +        filter_list.send_alert = True +        filter_list.server_message_embed = None +        filter_list.save() + + +class Migration(migrations.Migration): +    dependencies = [ +        ('api', '0078_merge_20211218_2200'), +    ] + +    operations = [ +        migrations.AddField( +            model_name='filter', +            name='send_alert', +            field=models.BooleanField(help_text='Whether alert should be sent.', null=True), +        ), +        migrations.AddField( +            model_name='filter', +            name='dm_embed', +            field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True), +        ), +        migrations.AddField( +            model_name='filterlist', +            name='send_alert', +            field=models.BooleanField(default=True, help_text='Whether alert should be sent.'), +        ), +        migrations.AddField( +            model_name='filterlist', +            name='dm_embed', +            field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True), +        ), +        migrations.RunPython(migrate_filterlist, unmigrate_filterlist) +    ] diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index fd5bf220..63087990 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -1,6 +1,7 @@  # flake8: noqa  from .bot import (      FilterList, +    Filter,      BotSetting,      DocumentationLink,      DeletedMessage, diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index ac864de3..9ba763a4 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -1,5 +1,5 @@  # flake8: noqa -from .filter_list import FilterList +from .filters import FilterList, Filter  from .bot_setting import BotSetting  from .deleted_message import DeletedMessage  from .documentation_link import DocumentationLink diff --git a/pydis_site/apps/api/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..97af21f8 --- /dev/null +++ b/pydis_site/apps/api/models/bot/filters.py @@ -0,0 +1,188 @@ +from typing import List + +from django.contrib.postgres.fields import ArrayField, JSONField +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import UniqueConstraint + +# 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 + + +# Valid special values in ping related fields +VALID_PINGS = ("everyone", "here", "moderators", "onduty", "admins") +VALID_BYPASS_ROLES = ("staff",) + + +def validate_ping_field(value_list: List[str]) -> None: +    """Validate that the values are either a special value or a UID.""" +    for value in value_list: +        # Check if it is a special value +        if value in VALID_PINGS: +            continue +        # Check if it is a UID +        if value.isnumeric(): +            continue + +        raise ValidationError(f"{value!r} isn't a valid ping type.") + + +def validate_bypass_roles_field(value_list: List[str]) -> None: +    """Validate that the vclues are either a special value or a Role ID.""" +    for value in value_list: +        if value.isnumeric() or value in VALID_BYPASS_ROLES: +            continue +        raise ValidationError(f"{value!r} isn't a valid (bypass) role.") + + +class FilterSettingsMixin(models.Model): +    """Mixin for common settings of a filters and filter lists.""" + +    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=Infraction.TYPE_CHOICES, +        max_length=9, +        null=True, +        help_text="The infraction to apply to this user." +    ) +    infraction_reason = models.CharField( +        max_length=1000, +        help_text="The reason to give for the infraction." +    ) +    infraction_duration = models.DurationField( +        null=True, +        help_text="The duration of the infraction. Null if permanent." +    ) + +    class 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" +    ) +    ping_type = ArrayField( +        models.CharField(max_length=20), +        validators=(validate_ping_field,), +        help_text="Who to ping when this filter triggers.", +        null=False +    ) +    filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False) +    dm_ping_type = ArrayField( +        models.CharField(max_length=20), +        validators=(validate_ping_field,), +        help_text="Who to ping when this filter triggers on a DM.", +        null=False +    ) +    delete_messages = models.BooleanField( +        help_text="Whether this filter should delete messages triggering it.", +        null=False +    ) +    bypass_roles = ArrayField( +        models.CharField(max_length=100), +        help_text="Roles and users who can bypass this filter.", +        validators=(validate_bypass_roles_field,), +        null=False +    ) +    enabled = models.BooleanField( +        help_text="Whether this filter is currently enabled.", +        null=False +    ) +    send_alert = models.BooleanField( +        help_text="Whether alert should be sent.", +        null=False, +        default=True +    ) +    # Where a filter should apply. +    # +    # The resolution is done in the following order: +    #   - enabled_channels +    #   - disabled_categories +    #   - disabled_channels +    enabled_channels = ArrayField(models.IntegerField()) +    disabled_channels = ArrayField(models.IntegerField()) +    disabled_categories = ArrayField(models.IntegerField()) + +    class Meta: +        """Constrain name and list_type unique.""" + +        constraints = ( +            UniqueConstraint(fields=("name", "list_type"), name="unique_name_type"), +        ) + +    def __str__(self) -> str: +        return f"Filter {FilterListType(self.list_type).label}list {self.name!r}" + + +class Filter(FilterSettingsMixin): +    """One specific trigger of a list.""" + +    content = models.CharField(max_length=100, help_text="The definition of this filter.") +    description = models.CharField(max_length=200, help_text="Why this filter has been added.") +    additional_field = JSONField(null=True, help_text="Implementation specific field.") +    filter_list = models.ForeignKey( +        FilterList, models.CASCADE, related_name="filters", +        help_text="The filter list containing this filter." +    ) +    ping_type = ArrayField( +        models.CharField(max_length=20), +        validators=(validate_ping_field,), +        help_text="Who to ping when this filter triggers.", +        null=True +    ) +    filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True) +    dm_ping_type = ArrayField( +        models.CharField(max_length=20), +        validators=(validate_ping_field,), +        help_text="Who to ping when this filter triggers on a DM.", +        null=True +    ) +    delete_messages = models.BooleanField( +        help_text="Whether this filter should delete messages triggering it.", +        null=True +    ) +    bypass_roles = ArrayField( +        models.CharField(max_length=100), +        help_text="Roles and users who can bypass this filter.", +        validators=(validate_bypass_roles_field,), +        null=True +    ) +    enabled = models.BooleanField( +        help_text="Whether this filter is currently enabled.", +        null=True +    ) +    send_alert = models.BooleanField( +        help_text="Whether alert should be sent.", +        null=True +    ) + +    # Check FilterList model for information about these properties. +    enabled_channels = ArrayField(models.IntegerField(), null=True) +    disabled_channels = ArrayField(models.IntegerField(), null=True) +    disabled_categories = ArrayField(models.IntegerField(), null=True) + +    def __str__(self) -> str: +        return f"Filter {self.content!r}" diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index ac05ebd4..88b6e2bd 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -12,12 +12,13 @@ from rest_framework.serializers import (  from rest_framework.settings import api_settings  from rest_framework.validators import UniqueTogetherValidator -from .models import ( +from .models import (  # noqa: I101 - Preserving the filter order      BotSetting,      DeletedMessage,      DocumentationLink, -    FilterList,      Infraction, +    FilterList, +    Filter,      MessageDeletionContext,      Nomination,      NominationEntry, @@ -112,30 +113,212 @@ class DocumentationLinkSerializer(ModelSerializer):          fields = ('package', 'base_url', 'inventory_url') +ALWAYS_OPTIONAL_SETTINGS = ( +    'dm_content', +    'infraction_type', +    'infraction_reason', +    'infraction_duration', +) + +REQUIRED_FOR_FILTER_LIST_SETTINGS = ( +    'ping_type', +    'filter_dm', +    'dm_ping_type', +    'delete_messages', +    'bypass_roles', +    'enabled', +    'enabled_channels', +    'disabled_channels', +    'disabled_categories', +) + +# Required fields for custom JSON representation purposes +BASE_FILTER_FIELDS = ('id', 'content', 'description', 'additional_field') +BASE_FILTERLIST_FIELDS = ('id', 'name', 'list_type') +BASE_SETTINGS_FIELDS = ( +    "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", +) +MENTIONS_FIELDS = ("ping_type", "dm_ping_type") + +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'] +            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 = 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.""" @@ -180,7 +363,7 @@ class InfractionSerializer(ModelSerializer):          if hidden and infr_type in ('superstar', 'warning', 'voice_ban'):              raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']}) -        if not hidden and infr_type in ('note', ): +        if not hidden and infr_type in ('note',):              raise ValidationError({'hidden': [f'{infr_type} infractions must be hidden.']})          return attrs diff --git a/pydis_site/apps/api/tests/test_filterlists.py b/pydis_site/apps/api/tests/test_filterlists.py deleted file mode 100644 index 5a5bca60..00000000 --- a/pydis_site/apps/api/tests/test_filterlists.py +++ /dev/null @@ -1,122 +0,0 @@ -from django.urls import reverse - -from pydis_site.apps.api.models import FilterList -from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase - -URL = reverse('api:bot:filterlist-list') -JPEG_ALLOWLIST = { -    "type": 'FILE_FORMAT', -    "allowed": True, -    "content": ".jpeg", -} -PNG_ALLOWLIST = { -    "type": 'FILE_FORMAT', -    "allowed": True, -    "content": ".png", -} - - -class UnauthenticatedTests(AuthenticatedAPITestCase): -    def setUp(self): -        super().setUp() -        self.client.force_authenticate(user=None) - -    def test_cannot_read_allowedlist_list(self): -        response = self.client.get(URL) - -        self.assertEqual(response.status_code, 401) - - -class EmptyDatabaseTests(AuthenticatedAPITestCase): -    @classmethod -    def setUpTestData(cls): -        FilterList.objects.all().delete() - -    def test_returns_empty_object(self): -        response = self.client.get(URL) - -        self.assertEqual(response.status_code, 200) -        self.assertEqual(response.json(), []) - - -class FetchTests(AuthenticatedAPITestCase): -    @classmethod -    def setUpTestData(cls): -        FilterList.objects.all().delete() -        cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST) -        cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST) - -    def test_returns_name_in_list(self): -        response = self.client.get(URL) - -        self.assertEqual(response.status_code, 200) -        self.assertEqual(response.json()[0]["content"], self.jpeg_format.content) -        self.assertEqual(response.json()[1]["content"], self.png_format.content) - -    def test_returns_single_item_by_id(self): -        response = self.client.get(f'{URL}/{self.jpeg_format.id}') - -        self.assertEqual(response.status_code, 200) -        self.assertEqual(response.json().get("content"), self.jpeg_format.content) - -    def test_returns_filter_list_types(self): -        response = self.client.get(f'{URL}/get-types') - -        self.assertEqual(response.status_code, 200) -        for api_type, model_type in zip(response.json(), FilterList.FilterListType.choices): -            self.assertEquals(api_type[0], model_type[0]) -            self.assertEquals(api_type[1], model_type[1]) - - -class CreationTests(AuthenticatedAPITestCase): -    @classmethod -    def setUpTestData(cls): -        FilterList.objects.all().delete() - -    def test_returns_400_for_missing_params(self): -        no_type_json = { -            "allowed": True, -            "content": ".jpeg" -        } -        no_allowed_json = { -            "type": "FILE_FORMAT", -            "content": ".jpeg" -        } -        no_content_json = { -            "allowed": True, -            "type": "FILE_FORMAT" -        } -        cases = [{}, no_type_json, no_allowed_json, no_content_json] - -        for case in cases: -            with self.subTest(case=case): -                response = self.client.post(URL, data=case) -                self.assertEqual(response.status_code, 400) - -    def test_returns_201_for_successful_creation(self): -        response = self.client.post(URL, data=JPEG_ALLOWLIST) -        self.assertEqual(response.status_code, 201) - -    def test_returns_400_for_duplicate_creation(self): -        self.client.post(URL, data=JPEG_ALLOWLIST) -        response = self.client.post(URL, data=JPEG_ALLOWLIST) -        self.assertEqual(response.status_code, 400) - - -class DeletionTests(AuthenticatedAPITestCase): -    @classmethod -    def setUpTestData(cls): -        FilterList.objects.all().delete() -        cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST) -        cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST) - -    def test_deleting_unknown_id_returns_404(self): -        response = self.client.delete(f"{URL}/200") -        self.assertEqual(response.status_code, 404) - -    def test_deleting_known_id_returns_204(self): -        response = self.client.delete(f"{URL}/{self.jpeg_format.id}") -        self.assertEqual(response.status_code, 204) - -        response = self.client.get(f"{URL}/{self.jpeg_format.id}") -        self.assertNotIn(self.png_format.content, response.json()) diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py new file mode 100644 index 00000000..f694053d --- /dev/null +++ b/pydis_site/apps/api/tests/test_filters.py @@ -0,0 +1,309 @@ +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) + +    def test_reject_invalid_ping(self) -> None: +        url = reverse('bot:filteroverride-list', host='api') +        data = { +            "ping_type": ["invalid"] +        } + +        response = self.client.post(url, data=data) + +        self.assertEqual(response.status_code, 400) +        self.assertDictEqual(response.json(), {'ping_type': ["'invalid' isn't a valid ping type."]}) diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index 5c9ddea4..c8f4e1b1 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -7,6 +7,9 @@ from django.utils import timezone  from pydis_site.apps.api.models import (      DeletedMessage,      DocumentationLink, +    Filter, +    FilterList, +    FilterSettings,      Infraction,      Message,      MessageDeletionContext, @@ -106,6 +109,17 @@ class StringDunderMethodTests(SimpleTestCase):              DocumentationLink(                  'test', 'http://example.com', 'http://example.com'              ), +            FilterList( +                name="forbidden_duckies", +                list_type=0, +                default_settings=FilterSettings() +            ), +            Filter( +                content="ducky_nsfw", +                description="This ducky is totally inappropriate!", +                additional_field=None, +                override=None +            ),              OffensiveMessage(                  id=602951077675139072,                  channel_id=291284109232308226, diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index b0ab545b..4e8edaf0 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -2,11 +2,12 @@ from django.urls import include, path  from rest_framework.routers import DefaultRouter  from .views import HealthcheckView, RulesView -from .viewsets import ( +from .viewsets import (  # noqa: I101 - Preserving the filter order      BotSettingViewSet,      DeletedMessageViewSet,      DocumentationLinkViewSet,      FilterListViewSet, +    FilterViewSet,      InfractionViewSet,      NominationViewSet,      OffTopicChannelNameViewSet, @@ -19,10 +20,14 @@ from .viewsets import (  # https://www.django-rest-framework.org/api-guide/routers/#defaultrouter  bot_router = DefaultRouter(trailing_slash=False)  bot_router.register( -    'filter-lists', +    'filter/filter_lists',      FilterListViewSet  )  bot_router.register( +    'filter/filters', +    FilterViewSet +) +bot_router.register(      'bot-settings',      BotSettingViewSet  ) diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index f133e77f..4cf4c655 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -1,10 +1,11 @@  # flake8: noqa  from .bot import ( -    FilterListViewSet,      BotSettingViewSet,      DeletedMessageViewSet,      DocumentationLinkViewSet,      InfractionViewSet, +    FilterListViewSet, +    FilterViewSet,      NominationViewSet,      OffensiveMessageViewSet,      OffTopicChannelNameViewSet, diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py index 84b87eab..4649fcde 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 .deleted_message import DeletedMessageViewSet  from .documentation_link import DocumentationLinkViewSet diff --git a/pydis_site/apps/api/viewsets/bot/filter_list.py b/pydis_site/apps/api/viewsets/bot/filter_list.py deleted file mode 100644 index 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() | 
