aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site/apps
diff options
context:
space:
mode:
authorGravatar Boris Muratov <[email protected]>2023-04-07 19:13:53 +0300
committerGravatar GitHub <[email protected]>2023-04-07 19:13:53 +0300
commit8c8d67cedc30dcb4f9903236ee8ffee315ea6ce1 (patch)
tree4d125388fefdd79e9e00479683dfbb9f7706b3e0 /pydis_site/apps
parentimprove wording of a couple sections (diff)
parentMerge pull request #932 from python-discord/filters_fix (diff)
Merge branch 'main' into config-scaffolding-guide
Diffstat (limited to 'pydis_site/apps')
-rw-r--r--pydis_site/apps/api/admin.py12
-rw-r--r--pydis_site/apps/api/migrations/0086_infraction_jump_url.py18
-rw-r--r--pydis_site/apps/api/migrations/0087_alter_mute_to_timeout.py25
-rw-r--r--pydis_site/apps/api/migrations/0088_new_filter_schema.py171
-rw-r--r--pydis_site/apps/api/migrations/0089_unique_constraint_filters.py26
-rw-r--r--pydis_site/apps/api/migrations/0090_unique_filter_list.py102
-rw-r--r--pydis_site/apps/api/migrations/0091_antispam_filter_list.py52
-rw-r--r--pydis_site/apps/api/models/__init__.py3
-rw-r--r--pydis_site/apps/api/models/bot/__init__.py2
-rw-r--r--pydis_site/apps/api/models/bot/filter_list.py42
-rw-r--r--pydis_site/apps/api/models/bot/filters.py261
-rw-r--r--pydis_site/apps/api/models/bot/infraction.py11
-rw-r--r--pydis_site/apps/api/serializers.py281
-rw-r--r--pydis_site/apps/api/tests/test_filterlists.py122
-rw-r--r--pydis_site/apps/api/tests/test_filters.py352
-rw-r--r--pydis_site/apps/api/tests/test_infractions.py24
-rw-r--r--pydis_site/apps/api/tests/test_models.py11
-rw-r--r--pydis_site/apps/api/urls.py9
-rw-r--r--pydis_site/apps/api/views.py4
-rw-r--r--pydis_site/apps/api/viewsets/__init__.py2
-rw-r--r--pydis_site/apps/api/viewsets/bot/__init__.py5
-rw-r--r--pydis_site/apps/api/viewsets/bot/filter_list.py98
-rw-r--r--pydis_site/apps/api/viewsets/bot/filters.py499
-rw-r--r--pydis_site/apps/api/viewsets/bot/infraction.py8
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/contributing/creating-bot-account.md6
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md4
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot/env-var-reference.md19
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md23
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/discordpy-subclassing-context.md129
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md2
-rw-r--r--pydis_site/apps/content/resources/guides/python-guides/why-not-json-as-database.md2
-rw-r--r--pydis_site/apps/content/resources/rules.md1
-rw-r--r--pydis_site/apps/content/resources/server-info/roles.md4
-rw-r--r--pydis_site/apps/content/tests/helpers.py59
-rw-r--r--pydis_site/apps/events/README.md19
-rw-r--r--pydis_site/apps/home/README.md35
-rw-r--r--pydis_site/apps/home/models.py (renamed from pydis_site/apps/home/models/repository_metadata.py)0
-rw-r--r--pydis_site/apps/home/models/__init__.py3
-rw-r--r--pydis_site/apps/home/views.py (renamed from pydis_site/apps/home/views/home.py)0
-rw-r--r--pydis_site/apps/home/views/__init__.py3
-rw-r--r--pydis_site/apps/redirect/README.md13
-rw-r--r--pydis_site/apps/resources/resources/python_org.yaml14
-rw-r--r--pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml2
43 files changed, 2124 insertions, 354 deletions
diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py
index 2aca38a1..e123d150 100644
--- a/pydis_site/apps/api/admin.py
+++ b/pydis_site/apps/api/admin.py
@@ -13,6 +13,8 @@ from .models import (
BotSetting,
DeletedMessage,
DocumentationLink,
+ Filter,
+ FilterList,
Infraction,
MessageDeletionContext,
Nomination,
@@ -194,6 +196,16 @@ class DeletedMessageInline(admin.TabularInline):
model = DeletedMessage
[email protected](FilterList)
+class FilterListAdmin(admin.ModelAdmin):
+ """Admin formatting for the FilterList model."""
+
+
+class FilterAdmin(admin.ModelAdmin):
+ """Admin formatting for the Filter model."""
+
+
@admin.register(MessageDeletionContext)
class MessageDeletionContextAdmin(admin.ModelAdmin):
"""Admin formatting for the MessageDeletionContext model."""
diff --git a/pydis_site/apps/api/migrations/0086_infraction_jump_url.py b/pydis_site/apps/api/migrations/0086_infraction_jump_url.py
new file mode 100644
index 00000000..7ae65751
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0086_infraction_jump_url.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.7 on 2023-03-10 17:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0085_add_thread_id_to_nominations'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='infraction',
+ name='jump_url',
+ field=models.URLField(default=None, help_text='The jump url to message invoking the infraction.', max_length=88, null=True),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0087_alter_mute_to_timeout.py b/pydis_site/apps/api/migrations/0087_alter_mute_to_timeout.py
new file mode 100644
index 00000000..8a826ba5
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0087_alter_mute_to_timeout.py
@@ -0,0 +1,25 @@
+from django.apps.registry import Apps
+from django.db import migrations, models
+
+import pydis_site.apps.api.models
+
+
+def rename_type(apps: Apps, _) -> None:
+ infractions: pydis_site.apps.api.models.Infraction = apps.get_model("api", "Infraction")
+ infractions.objects.filter(type="mute").update(type="timeout")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0086_infraction_jump_url'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='infraction',
+ name='type',
+ field=models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('timeout', 'Timeout'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban'), ('voice_mute', 'Voice Mute')], help_text='The type of the infraction.', max_length=10),
+ ),
+ migrations.RunPython(rename_type, migrations.RunPython.noop)
+ ]
diff --git a/pydis_site/apps/api/migrations/0088_new_filter_schema.py b/pydis_site/apps/api/migrations/0088_new_filter_schema.py
new file mode 100644
index 00000000..9bc40779
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0088_new_filter_schema.py
@@ -0,0 +1,171 @@
+"""Modified migration file to migrate existing filters to the new system."""
+from datetime import timedelta
+
+import django.contrib.postgres.fields
+from django.apps.registry import Apps
+from django.core.validators import MinValueValidator
+from django.db import migrations, models
+import django.db.models.deletion
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+import pydis_site.apps.api.models
+
+OLD_LIST_NAMES = (('GUILD_INVITE', True), ('GUILD_INVITE', False), ('FILE_FORMAT', True), ('DOMAIN_NAME', False), ('FILTER_TOKEN', False), ('REDIRECT', False))
+change_map = {
+ "FILTER_TOKEN": "token",
+ "DOMAIN_NAME": "domain",
+ "GUILD_INVITE": "invite",
+ "FILE_FORMAT": "extension",
+ "REDIRECT": "redirect"
+}
+
+
+def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
+ filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
+ filter_list_old = apps.get_model("api", "FilterListOld")
+
+ for name, type_ in OLD_LIST_NAMES:
+ objects = filter_list_old.objects.filter(type=name, allowed=type_)
+ if name == "DOMAIN_NAME":
+ dm_content = "Your message has been removed because it contained a blocked domain: `{domain}`."
+ elif name == "GUILD_INVITE":
+ dm_content = "Per Rule 6, your invite link has been removed. " \
+ "Our server rules can be found here: https://pythondiscord.com/pages/rules"
+ else:
+ dm_content = ""
+
+ list_ = filter_list.objects.create(
+ name=change_map[name],
+ list_type=int(type_),
+ guild_pings=(["Moderators"] if name != "FILE_FORMAT" else []),
+ filter_dm=True,
+ dm_pings=[],
+ remove_context=(True if name != "FILTER_TOKEN" else False),
+ bypass_roles=["Helpers"],
+ enabled=True,
+ dm_content=dm_content,
+ dm_embed="" if name != "FILE_FORMAT" else "*Defined at runtime.*",
+ infraction_type="NONE",
+ infraction_reason="",
+ infraction_duration=timedelta(seconds=0),
+ infraction_channel=0,
+ disabled_channels=[],
+ disabled_categories=(["CODE JAM"] if name in ("FILE_FORMAT", "GUILD_INVITE") else []),
+ enabled_channels=[],
+ enabled_categories=[],
+ send_alert=(name in ('GUILD_INVITE', 'DOMAIN_NAME', 'FILTER_TOKEN'))
+ )
+
+ for object_ in objects:
+ new_object = filter_.objects.create(
+ content=object_.content,
+ created_at=object_.created_at,
+ updated_at=object_.updated_at,
+ filter_list=list_,
+ description=object_.comment,
+ additional_settings={},
+ guild_pings=None,
+ filter_dm=None,
+ dm_pings=None,
+ remove_context=None,
+ bypass_roles=None,
+ enabled=None,
+ dm_content=None,
+ dm_embed=None,
+ infraction_type=None,
+ infraction_reason=None,
+ infraction_duration=None,
+ infraction_channel=None,
+ disabled_channels=None,
+ disabled_categories=None,
+ enabled_channels=None,
+ enabled_categories=None,
+ send_alert=None,
+ )
+ new_object.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0087_alter_mute_to_timeout'),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name='FilterList',
+ new_name='FilterListOld'
+ ),
+ migrations.CreateModel(
+ name='Filter',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('content', models.TextField(help_text='The definition of this filter.')),
+ ('description', models.TextField(help_text='Why this filter has been added.', null=True)),
+ ('additional_settings', models.JSONField(help_text='Additional settings which are specific to this filter.', default=dict)),
+ ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, null=True)),
+ ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)),
+ ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, null=True)),
+ ('remove_context', models.BooleanField(help_text='Whether this filter should remove the context (such as a message) triggering it.', null=True)),
+ ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, null=True)),
+ ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)),
+ ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True, blank=True)),
+ ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True, blank=True)),
+ ('infraction_type', models.CharField(choices=[('NONE', 'None'), ('NOTE', 'Note'), ('WARNING', 'Warning'), ('WATCH', 'Watch'), ('TIMEOUT', 'Timeout'), ('KICK', 'Kick'), ('BAN', 'Ban'), ('SUPERSTAR', 'Superstar'), ('VOICE_BAN', 'Voice Ban'), ('VOICE_MUTE', 'Voice Mute')], help_text='The infraction to apply to this user.', max_length=10, null=True)),
+ ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True, blank=True)),
+ ('infraction_duration', models.DurationField(help_text='The duration of the infraction. 0 for permanent.', null=True)),
+ ('infraction_channel', models.BigIntegerField(validators=(MinValueValidator(limit_value=0, message="Channel IDs cannot be negative."),), help_text="Channel in which to send the infraction.", null=True)),
+ ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter even if it's enabled in the category.", null=True, size=None)),
+ ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", null=True, size=None)),
+ ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", null=True, size=None)),
+ ('enabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="The only categories in which to run the filter.", null=True, size=None)),
+ ('send_alert', models.BooleanField(help_text='Whether an alert should be sent.', null=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='FilterList',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('name', models.CharField(help_text='The unique name of this list.', max_length=50)),
+ ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')),
+ ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None)),
+ ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')),
+ ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None)),
+ ('remove_context', models.BooleanField(help_text='Whether this filter should remove the context (such as a message) triggering it.')),
+ ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None)),
+ ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')),
+ ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, blank=True)),
+ ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, blank=True)),
+ ('infraction_type', models.CharField(choices=[('NONE', 'None'), ('NOTE', 'Note'), ('WARNING', 'Warning'), ('WATCH', 'Watch'), ('TIMEOUT', 'Timeout'), ('KICK', 'Kick'), ('BAN', 'Ban'), ('SUPERSTAR', 'Superstar'), ('VOICE_BAN', 'Voice Ban'), ('VOICE_MUTE', 'Voice Mute')], help_text='The infraction to apply to this user.', max_length=10)),
+ ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, blank=True)),
+ ('infraction_duration', models.DurationField(help_text='The duration of the infraction. 0 for permanent.')),
+ ('infraction_channel', models.BigIntegerField(validators=(MinValueValidator(limit_value=0, message="Channel IDs cannot be negative."),), help_text="Channel in which to send the infraction.")),
+ ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter even if it's enabled in the category.", size=None)),
+ ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", size=None)),
+ ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", size=None)),
+ ('enabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="The only categories in which to run the filter.", size=None)),
+ ('send_alert', models.BooleanField(help_text='Whether an alert should be sent.')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='filter',
+ name='filter_list',
+ field=models.ForeignKey(help_text='The filter list containing this filter.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='api.FilterList'),
+ ),
+ migrations.AddConstraint(
+ model_name='filterlist',
+ constraint=models.UniqueConstraint(fields=('name', 'list_type'), name='unique_name_type'),
+ ),
+ migrations.RunPython(
+ code=forward, # Core of the migration
+ reverse_code=lambda *_: None
+ ),
+ migrations.DeleteModel(
+ name='FilterListOld'
+ )
+ ]
diff --git a/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py
new file mode 100644
index 00000000..cb230a27
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py
@@ -0,0 +1,26 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0088_new_filter_schema'),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ "ALTER TABLE api_filter "
+ "ADD CONSTRAINT unique_filters UNIQUE NULLS NOT DISTINCT "
+ "(content, additional_settings, filter_list_id, dm_content, dm_embed, infraction_type, infraction_reason, infraction_duration, infraction_channel, guild_pings, filter_dm, dm_pings, remove_context, bypass_roles, enabled, send_alert, enabled_channels, disabled_channels, enabled_categories, disabled_categories)",
+ reverse_sql="ALTER TABLE api_filter DROP CONSTRAINT unique_filters",
+ state_operations=[
+ migrations.AddConstraint(
+ model_name='filter',
+ constraint=models.UniqueConstraint(
+ fields=('content', 'additional_settings', 'filter_list', 'dm_content', 'dm_embed', 'infraction_type', 'infraction_reason', 'infraction_duration', 'infraction_channel', 'guild_pings', 'filter_dm', 'dm_pings', 'remove_context', 'bypass_roles', 'enabled', 'send_alert', 'enabled_channels', 'disabled_channels', 'enabled_categories', 'disabled_categories'),
+ name='unique_filters'
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0090_unique_filter_list.py b/pydis_site/apps/api/migrations/0090_unique_filter_list.py
new file mode 100644
index 00000000..cef2faa3
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0090_unique_filter_list.py
@@ -0,0 +1,102 @@
+from datetime import timedelta
+
+from django.apps.registry import Apps
+from django.db import migrations
+
+import pydis_site.apps.api.models.bot.filters
+
+
+def create_unique_list(apps: Apps, _):
+ """Create the 'unique' FilterList and its related Filters."""
+ filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
+ filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
+
+ list_ = filter_list.objects.create(
+ name="unique",
+ list_type=0,
+ guild_pings=[],
+ filter_dm=True,
+ dm_pings=[],
+ remove_context=False,
+ bypass_roles=[],
+ enabled=True,
+ dm_content="",
+ dm_embed="",
+ infraction_type="NONE",
+ infraction_reason="",
+ infraction_duration=timedelta(seconds=0),
+ infraction_channel=0,
+ disabled_channels=[],
+ disabled_categories=[],
+ enabled_channels=[],
+ enabled_categories=[],
+ send_alert=True
+ )
+
+ everyone = filter_.objects.create(
+ content="everyone",
+ filter_list=list_,
+ description="",
+ remove_context=True,
+ bypass_roles=["Helpers"],
+ dm_content=(
+ "Please don't try to ping `@everyone` or `@here`. Your message has been removed. "
+ "If you believe this was a mistake, please let staff know!"
+ ),
+ disabled_categories=["CODE JAM"]
+ )
+ everyone.save()
+
+ webhook = filter_.objects.create(
+ content="webhook",
+ filter_list=list_,
+ description="",
+ remove_context=True,
+ dm_content=(
+ "Looks like you posted a Discord webhook URL. "
+ "Therefore, your message has been removed, and your webhook has been deleted. "
+ "You can re-create it if you wish to. "
+ "If you believe this was a mistake, please let us know."
+ ),
+ )
+ webhook.save()
+
+ rich_embed = filter_.objects.create(
+ content="rich_embed",
+ filter_list=list_,
+ description="",
+ guild_pings=["Moderators"],
+ dm_pings=["Moderators"]
+ )
+ rich_embed.save()
+
+ discord_token = filter_.objects.create(
+ content="discord_token",
+ filter_list=list_,
+ filter_dm=False,
+ remove_context=True,
+ dm_content=(
+ "I noticed you posted a seemingly valid Discord API "
+ "token in your message and have removed your message. "
+ "This means that your token has been **compromised**. "
+ "Please change your token **immediately** at: "
+ "<https://discord.com/developers/applications>\n\n"
+ "Feel free to re-post it with the token removed. "
+ "If you believe this was a mistake, please let us know!"
+ )
+ )
+ discord_token.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0089_unique_constraint_filters'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=create_unique_list,
+ reverse_code=None
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0091_antispam_filter_list.py b/pydis_site/apps/api/migrations/0091_antispam_filter_list.py
new file mode 100644
index 00000000..7c233142
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0091_antispam_filter_list.py
@@ -0,0 +1,52 @@
+from datetime import timedelta
+
+from django.apps.registry import Apps
+from django.db import migrations
+
+import pydis_site.apps.api.models.bot.filters
+
+
+def create_antispam_list(apps: Apps, _):
+ """Create the 'antispam' FilterList and its related Filters."""
+ filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
+ filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
+
+ list_ = filter_list.objects.create(
+ name="antispam",
+ list_type=0,
+ guild_pings=["Moderators"],
+ filter_dm=False,
+ dm_pings=[],
+ remove_context=True,
+ bypass_roles=["Helpers"],
+ enabled=True,
+ dm_content="",
+ dm_embed="",
+ infraction_type="TIMEOUT",
+ infraction_reason="",
+ infraction_duration=timedelta(seconds=600),
+ infraction_channel=0,
+ disabled_channels=[],
+ disabled_categories=["CODE JAM"],
+ enabled_channels=[],
+ enabled_categories=[],
+ send_alert=True
+ )
+
+ rules = ("duplicates", "attachments", "burst", "chars", "emoji", "links", "mentions", "newlines", "role_mentions")
+
+ filter_.objects.bulk_create([filter_(content=rule, filter_list=list_) for rule in rules])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0090_unique_filter_list'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=create_antispam_list,
+ reverse_code=None
+ ),
+ ]
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index a197e988..fee4c8d5 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -1,10 +1,11 @@
# flake8: noqa
from .bot import (
+ FilterList,
+ Filter,
BotSetting,
BumpedThread,
DocumentationLink,
DeletedMessage,
- FilterList,
Infraction,
Message,
MessageDeletionContext,
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index 013bb85e..6f09473d 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -1,9 +1,9 @@
# flake8: noqa
+from .filters import FilterList, Filter
from .bot_setting import BotSetting
from .bumped_thread import BumpedThread
from .deleted_message import DeletedMessage
from .documentation_link import DocumentationLink
-from .filter_list import FilterList
from .infraction import Infraction
from .message import Message
from .aoc_completionist_block import AocCompletionistBlock
diff --git a/pydis_site/apps/api/models/bot/filter_list.py b/pydis_site/apps/api/models/bot/filter_list.py
deleted file mode 100644
index d30f7213..00000000
--- a/pydis_site/apps/api/models/bot/filter_list.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from django.db import models
-
-from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin
-
-
-class FilterList(ModelTimestampMixin, ModelReprMixin, models.Model):
- """An item that is either allowed or denied."""
-
- FilterListType = models.TextChoices(
- 'FilterListType',
- 'GUILD_INVITE '
- 'FILE_FORMAT '
- 'DOMAIN_NAME '
- 'FILTER_TOKEN '
- 'REDIRECT '
- )
- type = models.CharField(
- max_length=50,
- help_text="The type of allowlist this is on.",
- choices=FilterListType.choices,
- )
- allowed = models.BooleanField(
- help_text="Whether this item is on the allowlist or the denylist."
- )
- content = models.TextField(
- help_text="The data to add to the allow or denylist."
- )
- comment = models.TextField(
- help_text="Optional comment on this entry.",
- null=True
- )
-
- class Meta:
- """Metaconfig for this model."""
-
- # This constraint ensures only one filterlist with the
- # same content can exist. This means that we cannot have both an allow
- # and a deny for the same item, and we cannot have duplicates of the
- # same item.
- constraints = [
- models.UniqueConstraint(fields=['content', 'type'], name='unique_filter_list'),
- ]
diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py
new file mode 100644
index 00000000..620031dc
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -0,0 +1,261 @@
+from django.contrib.postgres.fields import ArrayField
+from django.core.validators import MinValueValidator
+from django.db import models
+from django.db.models import UniqueConstraint
+
+# Must be imported that way to avoid circular imports
+from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin
+from .infraction import Infraction
+
+
+class FilterListType(models.IntegerChoices):
+ """Choice between allow or deny for a list type."""
+
+ ALLOW = 1
+ DENY = 0
+
+
+class FilterList(ModelTimestampMixin, ModelReprMixin, models.Model):
+ """Represent a list in its allow or deny form."""
+
+ name = models.CharField(max_length=50, help_text="The unique name of this list.")
+ list_type = models.IntegerField(
+ choices=FilterListType.choices,
+ help_text="Whether this list is an allowlist or denylist"
+ )
+ dm_content = models.CharField(
+ max_length=1000,
+ null=False,
+ blank=True,
+ help_text="The DM to send to a user triggering this filter."
+ )
+ dm_embed = models.CharField(
+ max_length=2000,
+ help_text="The content of the DM embed",
+ null=False,
+ blank=True
+ )
+ infraction_type = models.CharField(
+ choices=[
+ (choices[0].upper(), choices[1])
+ for choices in [("NONE", "None"), *Infraction.TYPE_CHOICES]
+ ],
+ max_length=10,
+ null=False,
+ help_text="The infraction to apply to this user."
+ )
+ infraction_reason = models.CharField(
+ max_length=1000,
+ help_text="The reason to give for the infraction.",
+ blank=True,
+ null=False
+ )
+ infraction_duration = models.DurationField(
+ null=False,
+ help_text="The duration of the infraction. 0 for permanent."
+ )
+ infraction_channel = models.BigIntegerField(
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Channel IDs cannot be negative."
+ ),
+ ),
+ help_text="Channel in which to send the infraction.",
+ null=False
+ )
+ guild_pings = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Who to ping when this filter triggers.",
+ null=False
+ )
+ filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False)
+ dm_pings = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Who to ping when this filter triggers on a DM.",
+ null=False
+ )
+ remove_context = models.BooleanField(
+ help_text=(
+ "Whether this filter should remove the context (such as a message) "
+ "triggering it."
+ ),
+ null=False
+ )
+ bypass_roles = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Roles and users who can bypass this filter.",
+ null=False
+ )
+ enabled = models.BooleanField(
+ help_text="Whether this filter is currently enabled.",
+ null=False
+ )
+ send_alert = models.BooleanField(
+ help_text="Whether an alert should be sent.",
+ )
+ # Where a filter should apply.
+ enabled_channels = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Channels in which to run the filter even if it's disabled in the category."
+ )
+ disabled_channels = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Channels in which to not run the filter even if it's enabled in the category."
+ )
+ enabled_categories = ArrayField(
+ models.CharField(max_length=100),
+ help_text="The only categories in which to run the filter."
+ )
+ disabled_categories = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Categories in which to not run the filter."
+ )
+
+ class Meta:
+ """Constrain name and list_type unique."""
+
+ constraints = (
+ UniqueConstraint(fields=("name", "list_type"), name="unique_name_type"),
+ )
+
+ def __str__(self) -> str:
+ return f"Filter {FilterListType(self.list_type).label}list {self.name!r}"
+
+
+class FilterBase(ModelTimestampMixin, ModelReprMixin, models.Model):
+ """One specific trigger of a list."""
+
+ content = models.TextField(help_text="The definition of this filter.")
+ description = models.TextField(
+ help_text="Why this filter has been added.", null=True
+ )
+ additional_settings = models.JSONField(
+ help_text="Additional settings which are specific to this filter.", default=dict
+ )
+ filter_list = models.ForeignKey(
+ FilterList, models.CASCADE, related_name="filters",
+ help_text="The filter list containing this filter."
+ )
+ dm_content = models.CharField(
+ max_length=1000,
+ null=True,
+ blank=True,
+ help_text="The DM to send to a user triggering this filter."
+ )
+ dm_embed = models.CharField(
+ max_length=2000,
+ help_text="The content of the DM embed",
+ null=True,
+ blank=True
+ )
+ infraction_type = models.CharField(
+ choices=[
+ (choices[0].upper(), choices[1])
+ for choices in [("NONE", "None"), *Infraction.TYPE_CHOICES]
+ ],
+ max_length=10,
+ null=True,
+ help_text="The infraction to apply to this user."
+ )
+ infraction_reason = models.CharField(
+ max_length=1000,
+ help_text="The reason to give for the infraction.",
+ blank=True,
+ null=True
+ )
+ infraction_duration = models.DurationField(
+ null=True,
+ help_text="The duration of the infraction. 0 for permanent."
+ )
+ infraction_channel = models.BigIntegerField(
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Channel IDs cannot be negative."
+ ),
+ ),
+ help_text="Channel in which to send the infraction.",
+ null=True
+ )
+ guild_pings = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Who to ping when this filter triggers.",
+ null=True
+ )
+ filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True)
+ dm_pings = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Who to ping when this filter triggers on a DM.",
+ null=True
+ )
+ remove_context = models.BooleanField(
+ help_text=(
+ "Whether this filter should remove the context (such as a message) "
+ "triggering it."
+ ),
+ null=True
+ )
+ bypass_roles = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Roles and users who can bypass this filter.",
+ null=True
+ )
+ enabled = models.BooleanField(
+ help_text="Whether this filter is currently enabled.",
+ null=True
+ )
+ send_alert = models.BooleanField(
+ help_text="Whether an alert should be sent.",
+ null=True
+ )
+
+ enabled_channels = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Channels in which to run the filter even if it's disabled in the category.",
+ null=True
+ )
+ disabled_channels = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Channels in which to not run the filter even if it's enabled in the category.",
+ null=True
+ )
+ enabled_categories = ArrayField(
+ models.CharField(max_length=100),
+ help_text="The only categories in which to run the filter.",
+ null=True
+ )
+ disabled_categories = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Categories in which to not run the filter.",
+ null=True
+ )
+
+ def __str__(self) -> str:
+ return f"Filter {self.content!r}"
+
+ class Meta:
+ """Metaclass for FilterBase to make it abstract model."""
+
+ abstract = True
+
+
+class Filter(FilterBase):
+ """
+ The main Filter models based on `FilterBase`.
+
+ The purpose to have this model is to have access to the Fields of the Filter model
+ and set the unique constraint based on those fields.
+ """
+
+ class Meta:
+ """Metaclass Filter to set the unique constraint."""
+
+ constraints = (
+ UniqueConstraint(
+ fields=tuple(
+ field.name for field in FilterBase._meta.fields
+ if field.name not in ("id", "description", "created_at", "updated_at")
+ ),
+ name="unique_filters"),
+ )
diff --git a/pydis_site/apps/api/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py
index 218ee5ec..381b5b9d 100644
--- a/pydis_site/apps/api/models/bot/infraction.py
+++ b/pydis_site/apps/api/models/bot/infraction.py
@@ -12,7 +12,7 @@ class Infraction(ModelReprMixin, models.Model):
("note", "Note"),
("warning", "Warning"),
("watch", "Watch"),
- ("mute", "Mute"),
+ ("timeout", "Timeout"),
("kick", "Kick"),
("ban", "Ban"),
("superstar", "Superstar"),
@@ -69,6 +69,15 @@ class Infraction(ModelReprMixin, models.Model):
help_text="Whether a DM was sent to the user when infraction was applied."
)
+ jump_url = models.URLField(
+ default=None,
+ null=True,
+ max_length=88,
+ help_text=(
+ "The jump url to message invoking the infraction."
+ )
+ )
+
def __str__(self):
"""Returns some info on the current infraction, for display purposes."""
s = f"#{self.id}: {self.type} on {self.user_id}"
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 4303e7d0..2186b02c 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -1,4 +1,7 @@
"""Converters from Django models to data interchange formats and back."""
+from datetime import timedelta
+from typing import Any
+
from django.db.models.query import QuerySet
from django.db.utils import IntegrityError
from rest_framework.exceptions import NotFound
@@ -19,6 +22,7 @@ from .models import (
BumpedThread,
DeletedMessage,
DocumentationLink,
+ Filter,
FilterList,
Infraction,
MessageDeletionContext,
@@ -141,30 +145,286 @@ class DocumentationLinkSerializer(ModelSerializer):
fields = ('package', 'base_url', 'inventory_url')
+# region: filters serializers
+
+SETTINGS_FIELDS = (
+ 'dm_content',
+ 'dm_embed',
+ 'infraction_type',
+ 'infraction_reason',
+ 'infraction_duration',
+ 'infraction_channel',
+ 'guild_pings',
+ 'filter_dm',
+ 'dm_pings',
+ 'remove_context',
+ 'send_alert',
+ 'bypass_roles',
+ 'enabled',
+ 'enabled_channels',
+ 'disabled_channels',
+ 'enabled_categories',
+ 'disabled_categories',
+)
+
+ALLOW_BLANK_SETTINGS = (
+ 'dm_content',
+ 'dm_embed',
+ 'infraction_reason',
+)
+
+ALLOW_EMPTY_SETTINGS = (
+ 'enabled_channels',
+ 'disabled_channels',
+ 'enabled_categories',
+ 'disabled_categories',
+ 'guild_pings',
+ 'dm_pings',
+ 'bypass_roles',
+)
+
+# Required fields for custom JSON representation purposes
+BASE_FILTER_FIELDS = (
+ 'id', 'created_at', 'updated_at', 'content', 'description', 'additional_settings'
+)
+BASE_FILTERLIST_FIELDS = ('id', 'created_at', 'updated_at', 'name', 'list_type')
+BASE_SETTINGS_FIELDS = (
+ 'bypass_roles',
+ 'filter_dm',
+ 'enabled',
+ 'remove_context',
+ 'send_alert'
+)
+INFRACTION_AND_NOTIFICATION_FIELDS = (
+ 'infraction_type',
+ 'infraction_reason',
+ 'infraction_duration',
+ 'infraction_channel',
+ 'dm_content',
+ 'dm_embed'
+)
+CHANNEL_SCOPE_FIELDS = (
+ 'disabled_channels',
+ 'disabled_categories',
+ 'enabled_channels',
+ 'enabled_categories'
+)
+MENTIONS_FIELDS = ('guild_pings', 'dm_pings')
+
+MAX_TIMEOUT_DURATION = timedelta(days=28)
+
+
+def _create_meta_extra_kwargs(*, for_filter: bool) -> dict[str, dict[str, bool]]:
+ """Create the extra kwargs for the Meta classes of the Filter and FilterList serializers."""
+ extra_kwargs = {}
+ for field in SETTINGS_FIELDS:
+ field_args = {'required': False, 'allow_null': True} if for_filter else {}
+ if field in ALLOW_BLANK_SETTINGS:
+ field_args['allow_blank'] = True
+ if field in ALLOW_EMPTY_SETTINGS:
+ field_args['allow_empty'] = True
+ extra_kwargs[field] = field_args
+ return extra_kwargs
+
+
+def get_field_value(data: dict, field_name: str) -> Any:
+ """Get the value directly from the key, or from the filter list if it's missing or is None."""
+ if data.get(field_name) is not None:
+ return data[field_name]
+ return getattr(data['filter_list'], field_name)
+
+
+class FilterSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `Filter` instances."""
+
+ def validate(self, data: dict) -> dict:
+ """Perform infraction data + allowed and disallowed lists validation."""
+ infraction_type = get_field_value(data, 'infraction_type')
+ infraction_duration = get_field_value(data, 'infraction_duration')
+ if (
+ (get_field_value(data, 'infraction_reason') or infraction_duration)
+ and infraction_type == 'NONE'
+ ):
+ raise ValidationError(
+ "Infraction type is required with infraction duration or reason."
+ )
+
+ if (
+ infraction_type == 'TIMEOUT'
+ and (not infraction_duration or infraction_duration > MAX_TIMEOUT_DURATION)
+ ):
+ raise ValidationError(
+ f"A timeout cannot be longer than {MAX_TIMEOUT_DURATION.days} days."
+ )
+
+ common_channels = (
+ set(get_field_value(data, 'disabled_channels'))
+ & set(get_field_value(data, 'enabled_channels'))
+ )
+ if common_channels:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled channels lists:"
+ f" {', '.join(repr(channel) for channel in common_channels)}."
+ )
+
+ common_categories = (
+ set(get_field_value(data, 'disabled_categories'))
+ & set(get_field_value(data, 'enabled_categories'))
+ )
+ if common_categories:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled categories lists:"
+ f" {', '.join(repr(category) for category in common_categories)}."
+ )
+
+ return data
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ model = Filter
+ fields = (
+ 'id',
+ 'created_at',
+ 'updated_at',
+ 'content',
+ 'description',
+ 'additional_settings',
+ 'filter_list'
+ ) + SETTINGS_FIELDS
+ extra_kwargs = _create_meta_extra_kwargs(for_filter=True)
+
+ def create(self, validated_data: dict) -> User:
+ """Override the create method to catch violations of the custom uniqueness constraint."""
+ try:
+ return super().create(validated_data)
+ except IntegrityError:
+ raise ValidationError(
+ "Check if a filter with this combination of content "
+ "and settings already exists in this filter list."
+ )
+
+ def to_representation(self, instance: Filter) -> dict:
+ """
+ Provides a custom JSON representation to the Filter Serializers.
+
+ This representation restructures how the Filter is represented.
+ It groups the Infraction, Channel and Mention related fields into their own separated group.
+
+ Furthermore, it puts the fields that meant to represent Filter settings,
+ into a sub-field called `settings`.
+ """
+ settings = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
+ settings['infraction_and_notification'] = {
+ name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS
+ }
+ settings['channel_scope'] = {
+ name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS
+ }
+ settings['mentions'] = {
+ name: getattr(instance, name) for name in MENTIONS_FIELDS
+ }
+
+ schema = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS}
+ schema['filter_list'] = instance.filter_list.id
+ schema['settings'] = settings
+ return schema
+
+
class FilterListSerializer(ModelSerializer):
"""A class providing (de-)serialization of `FilterList` instances."""
+ filters = FilterSerializer(many=True, read_only=True)
+
+ def validate(self, data: dict) -> dict:
+ """Perform infraction data + allow and disallowed lists validation."""
+ infraction_duration = data.get('infraction_duration')
+ if (
+ data.get('infraction_reason') or infraction_duration
+ ) and not data.get('infraction_type'):
+ raise ValidationError("Infraction type is required with infraction duration or reason")
+
+ if (
+ data.get('disabled_channels') is not None
+ and data.get('enabled_channels') is not None
+ ):
+ common_channels = set(data['disabled_channels']) & set(data['enabled_channels'])
+ if common_channels:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled channels lists:"
+ f" {', '.join(repr(channel) for channel in common_channels)}."
+ )
+
+ if (
+ data.get('infraction_type') == 'TIMEOUT'
+ and (not infraction_duration or infraction_duration > MAX_TIMEOUT_DURATION)
+ ):
+ raise ValidationError(
+ f"A timeout cannot be longer than {MAX_TIMEOUT_DURATION.days} days."
+ )
+
+ if (
+ data.get('disabled_categories') is not None
+ and data.get('enabled_categories') is not None
+ ):
+ common_categories = set(data['disabled_categories']) & set(data['enabled_categories'])
+ if common_categories:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled categories lists:"
+ f" {', '.join(repr(category) for category in common_categories)}."
+ )
+
+ return data
+
class Meta:
"""Metadata defined for the Django REST Framework."""
model = FilterList
- fields = ('id', 'created_at', 'updated_at', 'type', 'allowed', 'content', 'comment')
+ fields = (
+ 'id', 'created_at', 'updated_at', 'name', 'list_type', 'filters'
+ ) + SETTINGS_FIELDS
+ extra_kwargs = _create_meta_extra_kwargs(for_filter=False)
- # This validator ensures only one filterlist with the
- # same content can exist. This means that we cannot have both an allow
- # and a deny for the same item, and we cannot have duplicates of the
- # same item.
+ # Ensure there can only be one filter list with the same name and type.
validators = [
UniqueTogetherValidator(
queryset=FilterList.objects.all(),
- fields=['content', 'type'],
+ fields=('name', 'list_type'),
message=(
- "A filterlist for this item already exists. "
- "Please note that you cannot add the same item to both allow and deny."
+ "A filterlist with the same name and type already exists."
)
),
]
+ def to_representation(self, instance: FilterList) -> dict:
+ """
+ Provides a custom JSON representation to the FilterList Serializers.
+
+ This representation restructures how the Filter is represented.
+ It groups the Infraction, Channel, and Mention related fields
+ into their own separated groups.
+
+ Furthermore, it puts the fields that are meant to represent FilterList settings,
+ into a sub-field called `settings`.
+ """
+ schema = {name: getattr(instance, name) for name in BASE_FILTERLIST_FIELDS}
+ schema['filters'] = [
+ FilterSerializer(many=False).to_representation(instance=item)
+ for item in Filter.objects.filter(filter_list=instance.id)
+ ]
+
+ settings = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
+ settings['infraction_and_notification'] = {
+ name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS
+ }
+ settings['channel_scope'] = {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}
+ settings['mentions'] = {name: getattr(instance, name) for name in MENTIONS_FIELDS}
+
+ schema['settings'] = settings
+ return schema
+
+# endregion
+
class InfractionSerializer(ModelSerializer):
"""A class providing (de-)serialization of `Infraction` instances."""
@@ -184,7 +444,8 @@ class InfractionSerializer(ModelSerializer):
'type',
'reason',
'hidden',
- 'dm_sent'
+ 'dm_sent',
+ 'jump_url'
)
def validate(self, attrs: dict) -> dict:
@@ -203,7 +464,7 @@ class InfractionSerializer(ModelSerializer):
if hidden and infr_type in ('superstar', 'warning', 'voice_ban', 'voice_mute'):
raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']})
- if not hidden and infr_type in ('note', ):
+ if not hidden and infr_type in ('note',):
raise ValidationError({'hidden': [f'{infr_type} infractions must be hidden.']})
return attrs
diff --git a/pydis_site/apps/api/tests/test_filterlists.py b/pydis_site/apps/api/tests/test_filterlists.py
deleted file mode 100644
index 9959617e..00000000
--- a/pydis_site/apps/api/tests/test_filterlists.py
+++ /dev/null
@@ -1,122 +0,0 @@
-from django.urls import reverse
-
-from pydis_site.apps.api.models import FilterList
-from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase
-
-URL = reverse('api:bot:filterlist-list')
-JPEG_ALLOWLIST = {
- "type": 'FILE_FORMAT',
- "allowed": True,
- "content": ".jpeg",
-}
-PNG_ALLOWLIST = {
- "type": 'FILE_FORMAT',
- "allowed": True,
- "content": ".png",
-}
-
-
-class UnauthenticatedTests(AuthenticatedAPITestCase):
- def setUp(self):
- super().setUp()
- self.client.force_authenticate(user=None)
-
- def test_cannot_read_allowedlist_list(self):
- response = self.client.get(URL)
-
- self.assertEqual(response.status_code, 401)
-
-
-class EmptyDatabaseTests(AuthenticatedAPITestCase):
- @classmethod
- def setUpTestData(cls):
- FilterList.objects.all().delete()
-
- def test_returns_empty_object(self):
- response = self.client.get(URL)
-
- self.assertEqual(response.status_code, 200)
- self.assertEqual(response.json(), [])
-
-
-class FetchTests(AuthenticatedAPITestCase):
- @classmethod
- def setUpTestData(cls):
- FilterList.objects.all().delete()
- cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST)
- cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST)
-
- def test_returns_name_in_list(self):
- response = self.client.get(URL)
-
- self.assertEqual(response.status_code, 200)
- self.assertEqual(response.json()[0]["content"], self.jpeg_format.content)
- self.assertEqual(response.json()[1]["content"], self.png_format.content)
-
- def test_returns_single_item_by_id(self):
- response = self.client.get(f'{URL}/{self.jpeg_format.id}')
-
- self.assertEqual(response.status_code, 200)
- self.assertEqual(response.json().get("content"), self.jpeg_format.content)
-
- def test_returns_filter_list_types(self):
- response = self.client.get(f'{URL}/get-types')
-
- self.assertEqual(response.status_code, 200)
- for api_type, model_type in zip(response.json(), FilterList.FilterListType.choices):
- self.assertEqual(api_type[0], model_type[0])
- self.assertEqual(api_type[1], model_type[1])
-
-
-class CreationTests(AuthenticatedAPITestCase):
- @classmethod
- def setUpTestData(cls):
- FilterList.objects.all().delete()
-
- def test_returns_400_for_missing_params(self):
- no_type_json = {
- "allowed": True,
- "content": ".jpeg"
- }
- no_allowed_json = {
- "type": "FILE_FORMAT",
- "content": ".jpeg"
- }
- no_content_json = {
- "allowed": True,
- "type": "FILE_FORMAT"
- }
- cases = [{}, no_type_json, no_allowed_json, no_content_json]
-
- for case in cases:
- with self.subTest(case=case):
- response = self.client.post(URL, data=case)
- self.assertEqual(response.status_code, 400)
-
- def test_returns_201_for_successful_creation(self):
- response = self.client.post(URL, data=JPEG_ALLOWLIST)
- self.assertEqual(response.status_code, 201)
-
- def test_returns_400_for_duplicate_creation(self):
- self.client.post(URL, data=JPEG_ALLOWLIST)
- response = self.client.post(URL, data=JPEG_ALLOWLIST)
- self.assertEqual(response.status_code, 400)
-
-
-class DeletionTests(AuthenticatedAPITestCase):
- @classmethod
- def setUpTestData(cls):
- FilterList.objects.all().delete()
- cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST)
- cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST)
-
- def test_deleting_unknown_id_returns_404(self):
- response = self.client.delete(f"{URL}/200")
- self.assertEqual(response.status_code, 404)
-
- def test_deleting_known_id_returns_204(self):
- response = self.client.delete(f"{URL}/{self.jpeg_format.id}")
- self.assertEqual(response.status_code, 204)
-
- response = self.client.get(f"{URL}/{self.jpeg_format.id}")
- self.assertNotIn(self.png_format.content, response.json())
diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py
new file mode 100644
index 00000000..5059d651
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_filters.py
@@ -0,0 +1,352 @@
+import contextlib
+from dataclasses import dataclass
+from datetime import timedelta
+from typing import Any, Dict, Tuple, Type
+
+from django.db.models import Model
+from django.urls import reverse
+
+from pydis_site.apps.api.models.bot.filters import Filter, FilterList
+from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase
+
+
+@dataclass()
+class TestSequence:
+ model: Type[Model]
+ route: str
+ object: Dict[str, Any]
+ ignored_fields: Tuple[str, ...] = ()
+
+ def url(self, detail: bool = False) -> str:
+ return reverse(f'api:bot:{self.route}-{"detail" if detail else "list"}')
+
+
+FK_FIELDS: Dict[Type[Model], Tuple[str, ...]] = {
+ FilterList: (),
+ Filter: ("filter_list",),
+}
+
+
+def get_test_sequences() -> Dict[str, TestSequence]:
+ filter_list1_deny_dict = {
+ "name": "testname",
+ "list_type": 0,
+ "guild_pings": [],
+ "filter_dm": True,
+ "dm_pings": [],
+ "remove_context": False,
+ "bypass_roles": [],
+ "enabled": True,
+ "dm_content": "",
+ "dm_embed": "",
+ "infraction_type": "NONE",
+ "infraction_reason": "",
+ "infraction_duration": timedelta(seconds=0),
+ "infraction_channel": 0,
+ "disabled_channels": [],
+ "disabled_categories": [],
+ "enabled_channels": [],
+ "enabled_categories": [],
+ "send_alert": True
+ }
+ filter_list1_allow_dict = filter_list1_deny_dict.copy()
+ filter_list1_allow_dict["list_type"] = 1
+ filter_list1_allow = FilterList(**filter_list1_allow_dict)
+
+ return {
+ "filter_list1": TestSequence(
+ FilterList,
+ "filterlist",
+ filter_list1_deny_dict,
+ ignored_fields=("filters", "created_at", "updated_at")
+ ),
+ "filter_list2": TestSequence(
+ FilterList,
+ "filterlist",
+ {
+ "name": "testname2",
+ "list_type": 1,
+ "guild_pings": ["Moderators"],
+ "filter_dm": False,
+ "dm_pings": ["here"],
+ "remove_context": True,
+ "bypass_roles": ["123456"],
+ "enabled": False,
+ "dm_content": "testing testing",
+ "dm_embed": "one two three",
+ "infraction_type": "TIMEOUT",
+ "infraction_reason": "stop testing",
+ "infraction_duration": timedelta(seconds=10.5),
+ "infraction_channel": 123,
+ "disabled_channels": ["python-general"],
+ "disabled_categories": ["CODE JAM"],
+ "enabled_channels": ["mighty-mice"],
+ "enabled_categories": ["Lobby"],
+ "send_alert": False
+ },
+ ignored_fields=("filters", "created_at", "updated_at")
+ ),
+ "filter": TestSequence(
+ Filter,
+ "filter",
+ {
+ "content": "bad word",
+ "description": "This is a really bad word.",
+ "additional_settings": "{'hi': 'there'}",
+ "guild_pings": None,
+ "filter_dm": None,
+ "dm_pings": None,
+ "remove_context": None,
+ "bypass_roles": None,
+ "enabled": None,
+ "dm_content": None,
+ "dm_embed": None,
+ "infraction_type": None,
+ "infraction_reason": None,
+ "infraction_duration": None,
+ "infraction_channel": None,
+ "disabled_channels": None,
+ "disabled_categories": None,
+ "enabled_channels": None,
+ "enabled_categories": None,
+ "send_alert": None,
+ "filter_list": filter_list1_allow
+ },
+ ignored_fields=("created_at", "updated_at")
+ ),
+ }
+
+
+def save_nested_objects(object_: Model, save_root: bool = True) -> None:
+ for field in FK_FIELDS.get(object_.__class__, ()):
+ value = getattr(object_, field)
+ save_nested_objects(value)
+
+ if save_root:
+ object_.save()
+
+
+def clean_test_json(json: dict) -> dict:
+ for key, value in json.items():
+ if isinstance(value, Model):
+ json[key] = value.id
+ elif isinstance(value, timedelta):
+ json[key] = str(value.total_seconds())
+
+ return json
+
+
+def clean_api_json(json: dict, sequence: TestSequence) -> dict:
+ for field in sequence.ignored_fields + ("id",):
+ with contextlib.suppress(KeyError):
+ del json[field]
+
+ return json
+
+
+def flatten_settings(json: dict) -> dict:
+ settings = json.pop("settings", {})
+ flattened_settings = {}
+ for entry, value in settings.items():
+ if isinstance(value, dict):
+ flattened_settings.update(value)
+ else:
+ flattened_settings[entry] = value
+
+ json.update(flattened_settings)
+
+ return json
+
+
+class GenericFilterTests(AuthenticatedAPITestCase):
+
+ def test_cannot_read_unauthenticated(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ self.client.force_authenticate(user=None)
+
+ response = self.client.get(sequence.url())
+ self.assertEqual(response.status_code, 401)
+
+ def test_empty_database(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ response = self.client.get(sequence.url())
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), [])
+
+ def test_fetch(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ save_nested_objects(sequence.model(**sequence.object))
+
+ response = self.client.get(sequence.url())
+ self.assertDictEqual(
+ clean_test_json(sequence.object),
+ clean_api_json(flatten_settings(response.json()[0]), sequence)
+ )
+
+ def test_fetch_by_id(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ saved = sequence.model(**sequence.object)
+ save_nested_objects(saved)
+
+ response = self.client.get(f"{sequence.url()}/{saved.id}")
+ self.assertDictEqual(
+ clean_test_json(sequence.object),
+ clean_api_json(flatten_settings(response.json()), sequence)
+ )
+
+ def test_fetch_non_existing(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ response = self.client.get(f"{sequence.url()}/42")
+ self.assertEqual(response.status_code, 404)
+ self.assertDictEqual(response.json(), {'detail': 'Not found.'})
+
+ def test_creation(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ save_nested_objects(sequence.model(**sequence.object), False)
+ data = clean_test_json(sequence.object.copy())
+ response = self.client.post(sequence.url(), data=data)
+
+ self.assertEqual(response.status_code, 201)
+ self.assertDictEqual(
+ clean_api_json(flatten_settings(response.json()), sequence),
+ clean_test_json(sequence.object)
+ )
+
+ def test_creation_missing_field(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ ignored_fields = sequence.ignored_fields + ("id", "additional_settings")
+ with self.subTest(name=name):
+ saved = sequence.model(**sequence.object)
+ save_nested_objects(saved)
+ data = clean_test_json(sequence.object.copy())
+
+ for field in sequence.model._meta.get_fields():
+ with self.subTest(field=field):
+ if field.null or field.name in ignored_fields:
+ continue
+
+ test_data = data.copy()
+ del test_data[field.name]
+
+ response = self.client.post(sequence.url(), data=test_data)
+ self.assertEqual(response.status_code, 400)
+
+ def test_deletion(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ saved = sequence.model(**sequence.object)
+ save_nested_objects(saved)
+
+ response = self.client.delete(f"{sequence.url()}/{saved.id}")
+ self.assertEqual(response.status_code, 204)
+
+ def test_deletion_non_existing(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ response = self.client.delete(f"{sequence.url()}/42")
+ self.assertEqual(response.status_code, 404)
+
+
+class FilterValidationTests(AuthenticatedAPITestCase):
+
+ def test_filter_validation(self) -> None:
+ test_sequences = get_test_sequences()
+ base_filter = test_sequences["filter"]
+ base_filter_list = test_sequences["filter_list1"]
+ cases = (
+ ({"infraction_reason": "hi"}, {}, 400),
+ ({"infraction_duration": timedelta(seconds=10)}, {}, 400),
+ ({"infraction_reason": "hi"}, {"infraction_type": "NOTE"}, 200),
+ ({"infraction_type": "TIMEOUT", "infraction_duration": timedelta(days=30)}, {}, 400),
+ ({"infraction_duration": timedelta(seconds=10)}, {"infraction_type": "TIMEOUT"}, 200),
+ ({"enabled_channels": ["admins"]}, {}, 200),
+ ({"disabled_channels": ["123"]}, {}, 200),
+ ({"enabled_categories": ["CODE JAM"]}, {}, 200),
+ ({"disabled_categories": ["CODE JAM"]}, {}, 200),
+ ({"enabled_channels": ["admins"], "disabled_channels": ["123", "admins"]}, {}, 400),
+ ({"enabled_categories": ["admins"], "disabled_categories": ["123", "admins"]}, {}, 400),
+ ({"enabled_channels": ["admins"]}, {"disabled_channels": ["123", "admins"]}, 400),
+ ({"enabled_categories": ["admins"]}, {"disabled_categories": ["123", "admins"]}, 400),
+ )
+
+ for filter_settings, filter_list_settings, response_code in cases:
+ with self.subTest(
+ f_settings=filter_settings, fl_settings=filter_list_settings, response=response_code
+ ):
+ base_filter.model.objects.all().delete()
+ base_filter_list.model.objects.all().delete()
+
+ case_filter_dict = base_filter.object.copy()
+ case_fl_dict = base_filter_list.object.copy()
+ case_fl_dict.update(filter_list_settings)
+
+ case_fl = base_filter_list.model(**case_fl_dict)
+ case_filter_dict["filter_list"] = case_fl
+ case_filter = base_filter.model(**case_filter_dict)
+ save_nested_objects(case_filter)
+
+ filter_settings["filter_list"] = case_fl
+ response = self.client.patch(
+ f"{base_filter.url()}/{case_filter.id}", data=clean_test_json(filter_settings)
+ )
+ self.assertEqual(response.status_code, response_code)
+
+ def test_filter_list_validation(self) -> None:
+ test_sequences = get_test_sequences()
+ base_filter_list = test_sequences["filter_list1"]
+ cases = (
+ ({"infraction_reason": "hi"}, 400),
+ ({"infraction_duration": timedelta(seconds=10)}, 400),
+ ({"infraction_type": "TIMEOUT", "infraction_duration": timedelta(days=30)}, 400),
+ ({"infraction_reason": "hi", "infraction_type": "NOTE"}, 200),
+ ({"infraction_duration": timedelta(seconds=10), "infraction_type": "TIMEOUT"}, 200),
+ ({"enabled_channels": ["admins"]}, 200), ({"disabled_channels": ["123"]}, 200),
+ ({"enabled_categories": ["CODE JAM"]}, 200),
+ ({"disabled_categories": ["CODE JAM"]}, 200),
+ ({"enabled_channels": ["admins"], "disabled_channels": ["123", "admins"]}, 400),
+ ({"enabled_categories": ["admins"], "disabled_categories": ["123", "admins"]}, 400),
+ )
+
+ for filter_list_settings, response_code in cases:
+ with self.subTest(fl_settings=filter_list_settings, response=response_code):
+ base_filter_list.model.objects.all().delete()
+
+ case_fl_dict = base_filter_list.object.copy()
+ case_fl = base_filter_list.model(**case_fl_dict)
+ save_nested_objects(case_fl)
+
+ response = self.client.patch(
+ f"{base_filter_list.url()}/{case_fl.id}",
+ data=clean_test_json(filter_list_settings)
+ )
+ self.assertEqual(response.status_code, response_code)
+
+ def test_filter_unique_constraint(self) -> None:
+ test_filter = get_test_sequences()["filter"]
+ test_filter.model.objects.all().delete()
+ test_filter_object = test_filter.model(**test_filter.object)
+ save_nested_objects(test_filter_object, False)
+
+ response = self.client.post(test_filter.url(), data=clean_test_json(test_filter.object))
+ self.assertEqual(response.status_code, 201)
+
+ response = self.client.post(test_filter.url(), data=clean_test_json(test_filter.object))
+ self.assertEqual(response.status_code, 400)
diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py
index 89ee4e23..ceb5591b 100644
--- a/pydis_site/apps/api/tests/test_infractions.py
+++ b/pydis_site/apps/api/tests/test_infractions.py
@@ -68,10 +68,10 @@ class InfractionTests(AuthenticatedAPITestCase):
active=False,
inserted_at=dt(2020, 10, 10, 0, 1, 0, tzinfo=timezone.utc),
)
- cls.mute_permanent = Infraction.objects.create(
+ cls.timeout_permanent = Infraction.objects.create(
user_id=cls.user.id,
actor_id=cls.user.id,
- type='mute',
+ type='timeout',
reason='He has a filthy mouth and I am his soap.',
active=True,
inserted_at=dt(2020, 10, 10, 0, 2, 0, tzinfo=timezone.utc),
@@ -107,7 +107,7 @@ class InfractionTests(AuthenticatedAPITestCase):
self.assertEqual(len(infractions), 5)
self.assertEqual(infractions[0]['id'], self.voiceban_expires_later.id)
self.assertEqual(infractions[1]['id'], self.superstar_expires_soon.id)
- self.assertEqual(infractions[2]['id'], self.mute_permanent.id)
+ self.assertEqual(infractions[2]['id'], self.timeout_permanent.id)
self.assertEqual(infractions[3]['id'], self.ban_inactive.id)
self.assertEqual(infractions[4]['id'], self.ban_hidden.id)
@@ -134,7 +134,7 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_filter_permanent_false(self):
url = reverse('api:bot:infraction-list')
- response = self.client.get(f'{url}?type=mute&permanent=false')
+ response = self.client.get(f'{url}?type=timeout&permanent=false')
self.assertEqual(response.status_code, 200)
infractions = response.json()
@@ -143,12 +143,12 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_filter_permanent_true(self):
url = reverse('api:bot:infraction-list')
- response = self.client.get(f'{url}?type=mute&permanent=true')
+ response = self.client.get(f'{url}?type=timeout&permanent=true')
self.assertEqual(response.status_code, 200)
infractions = response.json()
- self.assertEqual(infractions[0]['id'], self.mute_permanent.id)
+ self.assertEqual(infractions[0]['id'], self.timeout_permanent.id)
def test_filter_after(self):
url = reverse('api:bot:infraction-list')
@@ -241,7 +241,7 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_filter_manytypes(self):
url = reverse('api:bot:infraction-list')
- response = self.client.get(f'{url}?types=mute,ban')
+ response = self.client.get(f'{url}?types=timeout,ban')
self.assertEqual(response.status_code, 200)
infractions = response.json()
@@ -249,7 +249,7 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_types_type_invalid(self):
url = reverse('api:bot:infraction-list')
- response = self.client.get(f'{url}?types=mute,ban&type=superstar')
+ response = self.client.get(f'{url}?types=timeout,ban&type=superstar')
self.assertEqual(response.status_code, 400)
errors = list(response.json())
@@ -519,7 +519,7 @@ class CreationTests(AuthenticatedAPITestCase):
def test_returns_400_for_second_active_infraction_of_the_same_type(self):
"""Test if the API rejects a second active infraction of the same type for a given user."""
url = reverse('api:bot:infraction-list')
- active_infraction_types = ('mute', 'ban', 'superstar')
+ active_infraction_types = ('timeout', 'ban', 'superstar')
for infraction_type in active_infraction_types:
with self.subTest(infraction_type=infraction_type):
@@ -562,7 +562,7 @@ class CreationTests(AuthenticatedAPITestCase):
first_active_infraction = {
'user': self.user.id,
'actor': self.user.id,
- 'type': 'mute',
+ 'type': 'timeout',
'reason': 'Be silent!',
'hidden': True,
'active': True,
@@ -649,9 +649,9 @@ class CreationTests(AuthenticatedAPITestCase):
Infraction.objects.create(
user=self.user,
actor=self.user,
- type="mute",
+ type="timeout",
active=True,
- reason="The first active mute"
+ reason="The first active timeout"
)
def test_unique_constraint_accepts_active_infractions_for_different_users(self):
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index c07d59cd..d3341b35 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -6,6 +6,8 @@ from django.test import SimpleTestCase, TestCase
from pydis_site.apps.api.models import (
DeletedMessage,
DocumentationLink,
+ Filter,
+ FilterList,
Infraction,
MessageDeletionContext,
Nomination,
@@ -104,6 +106,15 @@ class StringDunderMethodTests(SimpleTestCase):
DocumentationLink(
'test', 'http://example.com', 'http://example.com'
),
+ FilterList(
+ name="forbidden_duckies",
+ list_type=0,
+ ),
+ Filter(
+ content="ducky_nsfw",
+ description="This ducky is totally inappropriate!",
+ additional_settings=None,
+ ),
OffensiveMessage(
id=602951077675139072,
channel_id=291284109232308226,
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index 2757f176..f872ba92 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -10,6 +10,7 @@ from .viewsets import (
DeletedMessageViewSet,
DocumentationLinkViewSet,
FilterListViewSet,
+ FilterViewSet,
InfractionViewSet,
NominationViewSet,
OffTopicChannelNameViewSet,
@@ -22,6 +23,10 @@ from .viewsets import (
# https://www.django-rest-framework.org/api-guide/routers/#defaultrouter
bot_router = DefaultRouter(trailing_slash=False)
bot_router.register(
+ 'filter/filter_lists',
+ FilterListViewSet
+)
+bot_router.register(
"aoc-account-links",
AocAccountLinkViewSet
)
@@ -30,6 +35,10 @@ bot_router.register(
AocCompletionistBlockViewSet
)
bot_router.register(
+ 'filter/filters',
+ FilterViewSet
+)
+bot_router.register(
'bot-settings',
BotSettingViewSet
)
diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py
index 34167a38..20431a61 100644
--- a/pydis_site/apps/api/views.py
+++ b/pydis_site/apps/api/views.py
@@ -171,6 +171,10 @@ class RulesView(APIView):
"Do not offer or ask for paid work of any kind.",
["paid", "work", "money"]
),
+ (
+ "Do not copy and paste answers from ChatGPT or similar AI tools.",
+ ["gpt", "chatgpt", "gpt3", "ai"]
+ ),
])
diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
index ec52416a..1dae9be1 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -6,6 +6,8 @@ from .bot import (
DocumentationLinkViewSet,
FilterListViewSet,
InfractionViewSet,
+ FilterListViewSet,
+ FilterViewSet,
NominationViewSet,
OffensiveMessageViewSet,
AocAccountLinkViewSet,
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
index 262aa59f..33b65009 100644
--- a/pydis_site/apps/api/viewsets/bot/__init__.py
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -1,5 +1,8 @@
# flake8: noqa
-from .filter_list import FilterListViewSet
+from .filters import (
+ FilterListViewSet,
+ FilterViewSet
+)
from .bot_setting import BotSettingViewSet
from .bumped_thread import BumpedThreadViewSet
from .deleted_message import DeletedMessageViewSet
diff --git a/pydis_site/apps/api/viewsets/bot/filter_list.py b/pydis_site/apps/api/viewsets/bot/filter_list.py
deleted file mode 100644
index 4b05acee..00000000
--- a/pydis_site/apps/api/viewsets/bot/filter_list.py
+++ /dev/null
@@ -1,98 +0,0 @@
-from rest_framework.decorators import action
-from rest_framework.request import Request
-from rest_framework.response import Response
-from rest_framework.viewsets import ModelViewSet
-
-from pydis_site.apps.api.models.bot.filter_list import FilterList
-from pydis_site.apps.api.serializers import FilterListSerializer
-
-
-class FilterListViewSet(ModelViewSet):
- """
- View providing CRUD operations on items allowed or denied by our bot.
-
- ## Routes
- ### GET /bot/filter-lists
- Returns all filterlist items in the database.
-
- #### Response format
- >>> [
- ... {
- ... 'id': "2309268224",
- ... 'created_at': "01-01-2020 ...",
- ... 'updated_at': "01-01-2020 ...",
- ... 'type': "file_format",
- ... 'allowed': 'true',
- ... 'content': ".jpeg",
- ... 'comment': "Popular image format.",
- ... },
- ... ...
- ... ]
-
- #### Status codes
- - 200: returned on success
- - 401: returned if unauthenticated
-
- ### GET /bot/filter-lists/<id:int>
- Returns a specific FilterList item from the database.
-
- #### Response format
- >>> {
- ... 'id': "2309268224",
- ... 'created_at': "01-01-2020 ...",
- ... 'updated_at': "01-01-2020 ...",
- ... 'type': "file_format",
- ... 'allowed': 'true',
- ... 'content': ".jpeg",
- ... 'comment': "Popular image format.",
- ... }
-
- #### Status codes
- - 200: returned on success
- - 404: returned if the id was not found.
-
- ### GET /bot/filter-lists/get-types
- Returns a list of valid list types that can be used in POST requests.
-
- #### Response format
- >>> [
- ... ["GUILD_INVITE","Guild Invite"],
- ... ["FILE_FORMAT","File Format"],
- ... ["DOMAIN_NAME","Domain Name"],
- ... ["FILTER_TOKEN","Filter Token"],
- ... ["REDIRECT", "Redirect"]
- ... ]
-
- #### Status codes
- - 200: returned on success
-
- ### POST /bot/filter-lists
- Adds a single FilterList item to the database.
-
- #### Request body
- >>> {
- ... 'type': str,
- ... 'allowed': bool,
- ... 'content': str,
- ... 'comment': Optional[str],
- ... }
-
- #### Status codes
- - 201: returned on success
- - 400: if one of the given fields is invalid
-
- ### DELETE /bot/filter-lists/<id:int>
- Deletes the FilterList item with the given `id`.
-
- #### Status codes
- - 204: returned on success
- - 404: if a tag with the given `id` does not exist
- """
-
- serializer_class = FilterListSerializer
- queryset = FilterList.objects.all()
-
- @action(detail=False, url_path='get-types', methods=["get"])
- def get_types(self, _: Request) -> Response:
- """Get a list of all the types of FilterLists we support."""
- return Response(FilterList.FilterListType.choices)
diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
new file mode 100644
index 00000000..d6c2d18c
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -0,0 +1,499 @@
+from rest_framework.viewsets import ModelViewSet
+
+from pydis_site.apps.api.models.bot.filters import ( # noqa: I101 - Preserving the filter order
+ FilterList,
+ Filter
+)
+from pydis_site.apps.api.serializers import ( # noqa: I101 - Preserving the filter order
+ FilterListSerializer,
+ FilterSerializer,
+)
+
+
+class FilterListViewSet(ModelViewSet):
+ """
+ View providing GET/DELETE on lists of items allowed or denied by our bot.
+
+ ## Routes
+ ### GET /bot/filter/filter_lists
+ Returns all FilterList items in the database.
+
+ #### Response format
+ >>> [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.027293Z",
+ ... "updated_at": "2023-01-27T21:26:34.027308Z",
+ ... "name": "invite",
+ ... "list_type": 1,
+ ... "filters": [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ],
+ ... "settings": {
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_and_notification": {
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": ""
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... },
+ ... "mentions": {
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+ - 401: returned if unauthenticated
+
+ ### GET /bot/filter/filter_lists/<id:int>
+ Returns a specific FilterList item from the database.
+
+ #### Response format
+ >>> {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.027293Z",
+ ... "updated_at": "2023-01-27T21:26:34.027308Z",
+ ... "name": "invite",
+ ... "list_type": 1,
+ ... "filters": [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ],
+ ... "settings": {
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_and_notification": {
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": ""
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... },
+ ... "mentions": {
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+ ... }
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned if the id was not found.
+
+ ### POST /bot/filter/filter_lists
+ Adds a single FilterList item to the database.
+
+ #### Request body
+ >>> {
+ ... "name": "invite",
+ ... "list_type": 1,
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": "",
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### PATCH /bot/filter/filter_lists/<id:int>
+ Updates a specific FilterList item from the database.
+
+ #### Response format
+ >>> {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.027293Z",
+ ... "updated_at": "2023-01-27T21:26:34.027308Z",
+ ... "name": "invite",
+ ... "list_type": 1,
+ ... "filters": [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ],
+ ... "settings": {
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_and_notification": {
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": ""
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... },
+ ... "mentions": {
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+ ... }
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### DELETE /bot/filter/filter_lists/<id:int>
+ Deletes the FilterList item with the given `id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a FilterList with the given `id` does not exist
+ """
+
+ serializer_class = FilterListSerializer
+ queryset = FilterList.objects.all()
+
+
+class FilterViewSet(ModelViewSet):
+ """
+ View providing CRUD operations on items allowed or denied by our bot.
+
+ ## Routes
+ ### GET /bot/filter/filters
+ Returns all Filter items in the database.
+
+ #### Response format
+ >>> [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+ - 401: returned if unauthenticated
+
+ ### GET /bot/filter/filters/<id:int>
+ Returns a specific Filter item from the database.
+
+ #### Response format
+ >>> {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned if the id was not found.
+
+ ### POST /bot/filter/filters
+ Adds a single Filter item to the database.
+
+ #### Request body
+ >>> {
+ ... "filter_list": 1,
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": False,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### PATCH /bot/filter/filters/<id:int>
+ Updates a specific Filter item from the database.
+
+ #### Response format
+ >>> {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### DELETE /bot/filter/filters/<id:int>
+ Deletes the Filter item with the given `id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a Filter with the given `id` does not exist
+ """
+
+ serializer_class = FilterSerializer
+ queryset = Filter.objects.all()
diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py
index 93d29391..ec8b83a1 100644
--- a/pydis_site/apps/api/viewsets/bot/infraction.py
+++ b/pydis_site/apps/api/viewsets/bot/infraction.py
@@ -72,7 +72,8 @@ class InfractionViewSet(
... 'type': 'ban',
... 'reason': 'He terk my jerb!',
... 'hidden': True,
- ... 'dm_sent': True
+ ... 'dm_sent': True,
+ ... 'jump_url': '<discord message link>'
... }
... ]
@@ -103,7 +104,8 @@ class InfractionViewSet(
... 'type': 'ban',
... 'reason': 'He terk my jerb!',
... 'user': 172395097705414656,
- ... 'dm_sent': False
+ ... 'dm_sent': False,
+ ... 'jump_url': '<discord message link>'
... }
#### Response format
@@ -138,7 +140,7 @@ class InfractionViewSet(
#### Status codes
- 204: returned on success
- - 404: if a infraction with the given `id` does not exist
+ - 404: if an infraction with the given `id` does not exist
### Expanded routes
All routes support expansion of `user` and `actor` in responses. To use an expanded route,
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/creating-bot-account.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/creating-bot-account.md
index ee38baa3..51da3f34 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/creating-bot-account.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/creating-bot-account.md
@@ -9,9 +9,9 @@ icon: fab fa-discord
4. Change your bot's `Public Bot` setting off so only you can invite it, save, and then get your **Bot Token** with the `Copy` button.
> **Note:** **DO NOT** post your bot token anywhere public. If you do it can and will be compromised.
5. Save your **Bot Token** somewhere safe to use in the project settings later.
-6. In the `General Information` tab, grab the **Client ID**.
-7. Replace `<CLIENT_ID_HERE>` in the following URL and visit it in the browser to invite your bot to your new test server.
+6. In the `General Information` tab, grab the **Application ID**.
+7. Replace `<APPLICATION_ID_HERE>` in the following URL and visit it in the browser to invite your bot to your new test server.
```plaintext
-https://discordapp.com/api/oauth2/authorize?client_id=<CLIENT_ID_HERE>&permissions=8&scope=bot
+https://discordapp.com/api/oauth2/authorize?client_id=<APPLICATION_ID_HERE>&permissions=8&scope=bot
```
Optionally, you can generate your own invite url in the `OAuth` tab, after selecting `bot` as the scope.
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md
index c9566d23..edfd7ac1 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md
@@ -10,7 +10,7 @@ You should have already forked the [`sir-lancebot`](https://github.com/python-di
Remember to ensure that you have read the [contributing guidelines](../contributing-guidelines) in full before you start contributing.
### Requirements
-- [Python 3.9](https://www.python.org/downloads/)
+- [Python 3.10.*](https://www.python.org/downloads/)
- [Poetry](https://github.com/python-poetry/poetry#installation)
- [Git](https://git-scm.com/downloads)
- [Windows Installer](https://git-scm.com/download/win)
@@ -59,7 +59,7 @@ You will need your own test server and bot account on Discord to test your chang
* `#dev-log`
* `#sir-lancebot-commands`
4. Create the following roles:
- * `@Admin`
+ * `@Admins`
5. Note down the IDs for your server, as well as any channels and roles created.
* [**Learn how to obtain the ID of a server, channel or role here.**](../setting-test-server-and-bot-account#obtain-the-ids)
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot/env-var-reference.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot/env-var-reference.md
index 51587aac..3862fb2e 100644
--- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot/env-var-reference.md
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot/env-var-reference.md
@@ -10,12 +10,12 @@ The following variables are needed for running Sir Lancebot:
| -------- | -------- |
| `BOT_TOKEN` | Bot Token from the [Discord developer portal](https://discord.com/developers/applications) |
| `BOT_GUILD` | ID of the Discord Server |
-| `BOT_ADMIN_ROLE_ID` | ID of the role @Admins |
-| `ROLE_HELPERS` | ID of the role @Helpers |
-| `CHANNEL_ANNOUNCEMENTS` | ID of the #announcements channel |
-| `CHANNEL_DEVLOG` | ID of the #dev-log channel |
-| `CHANNEL_COMMUNITY_BOT_COMMANDS` | ID of the #sir-lancebot-commands channel |
-| `CHANNEL_REDDIT` | ID of the #reddit channel |
+| `BOT_ADMIN_ROLE_ID` | ID of the role `@Admins` |
+| `ROLE_HELPERS` | ID of the role `@Helpers` |
+| `CHANNEL_ANNOUNCEMENTS` | ID of the `#announcements` channel |
+| `CHANNEL_DEVLOG` | ID of the `#dev-log` channel |
+| `CHANNEL_COMMUNITY_BOT_COMMANDS` | ID of the `#sir-lancebot-commands` channel |
+| `CHANNEL_REDDIT` | ID of the `#reddit` channel |
---
## Debug Variables
@@ -32,8 +32,7 @@ Additionally, you may find the following environment variables useful during dev
| `REDIS_PASSWORD` | |
| `USE_FAKEREDIS` | If the FakeRedis module should be used. Set this to true if you don't have a Redis database setup. |
| `BOT_SENTRY_DSN` | The DSN of the sentry monitor. |
-| `TRASHCAN_EMOJI` | The full emoji to use for the trashcan. Format should be like the output of `\:emoji:`. |
-
+| `TRASHCAN_EMOJI` | The full emoji to use for the trashcan. Format should be like the output of sending `\:emoji:` on discord. |
---
## Tokens/APIs
@@ -66,8 +65,8 @@ These variables might come in handy while working on certain cogs:
| Advent of Code | `AOC_ROLE_ID` | ID of the advent of code role.
| Advent of Code | `AOC_IGNORED_DAYS` | Comma separated list of days to ignore while calculating score. |
| Advent of Code | `AOC_YEAR` | Debug variable to change the year used for AoC. |
-| Advent of Code | `AOC_CHANNEL_ID` | The ID of the #advent-of-code channel |
-| Advent of Code | `AOC_COMMANDS_CHANNEL_ID` | The ID of the #advent-of-code-commands channel |
+| Advent of Code | `AOC_CHANNEL_ID` | The ID of the `#advent-of-code` channel |
+| Advent of Code | `AOC_COMMANDS_CHANNEL_ID` | The ID of the `#advent-of-code-commands` channel |
| Advent of Code | `AOC_FALLBACK_SESSION` | |
| Advent of Code | `AOC_SESSION_COOKIE` | |
| Valentines | `LOVEFEST_ROLE_ID` | |
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
index 62ff61f9..0e88490e 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md
@@ -3,9 +3,10 @@ title: Discord Messages with Colors
description: A guide on how to add colors to your codeblocks on Discord
---
-Discord is now slowly rolling out the ability to send colored text within code blocks. This is done using ANSI color codes which is also how you print colored text in your terminal.
+Discord is now *slowly rolling out* the ability to send colored messages within code blocks. It uses the ANSI color codes, so if you've tried to print colored text in your terminal or console with Python or other languages then it will be easy for you.
-To send colored text in a code block you need to first specify the `ansi` language and use the prefixes similar to the one below:
+## Quick Explanation
+To be able to send a colored text, you need to use the `ansi` language for your code block and provide a prefix of this format before writing your text:
```ansi
\u001b[{format};{color}m
```
@@ -13,15 +14,17 @@ To send colored text in a code block you need to first specify the `ansi` langua
After you've written this, you can now type the text you wish to color. If you want to reset the color back to normal, then you need to use the `\u001b[0m` prefix again.
+## Formats
Here is the list of values you can use to replace `{format}`:
* 0: Normal
* 1: **Bold**
* 4: <ins>Underline</ins>
+## Colors
Here is the list of values you can use to replace `{color}`:
-*The following values will change the **text** color.*
+### Text Colors
* 30: Gray
* 31: Red
@@ -32,7 +35,7 @@ Here is the list of values you can use to replace `{color}`:
* 36: Cyan
* 37: White
-*The following values will change the **text background** color.*
+### Background Colors
* 40: Firefly dark blue
* 41: Orange
@@ -43,7 +46,9 @@ Here is the list of values you can use to replace `{color}`:
* 46: Light gray
* 47: White
-Let's take an example, I want a bold green colored text with the very dark blue background.
+## Example
+
+Let's take an example, I want a bold green colored text with the firefly dark blue background.
I simply use `\u001b[0;40m` (background color) and `\u001b[1;32m` (text color) as prefix. Note that the order is **important**, first you give the background color and then the text color.
Alternatively you can also directly combine them into a single prefix like the following: `\u001b[1;40;32m` and you can also use multiple values. Something like `\u001b[1;40;4;32m` would underline the text, make it bold, make it green and have a dark blue background.
@@ -61,8 +66,14 @@ Result:
![Background and text color result](/static/images/content/discord_colored_messages/result.png)
+### ANSI Colors Showcase
+
The way the colors look like on Discord is shown in the image below:
![ANSI Colors](/static/images/content/discord_colored_messages/ansi-colors.png)
-Note: If the change as not been brought to you yet, or other users, then you can use other code blocks in the meantime to get colored text. See **[this gist](https://gist.github.com/matthewzring/9f7bbfd102003963f9be7dbcf7d40e51)**.
+*Message sent to get the output of above can be found [here](https://gist.github.com/kkrypt0nn/a02506f3712ff2d1c8ca7c9e0aed7c06#file-ansi-colors-showcase-md).*
+
+#### Disclaimer
+
+***Note**: The change has been brought to all stable desktop clients. Since syntax highlighting on mobile is far behind, ANSI is not supported on mobile as well. Refer to [this gist](https://gist.github.com/matthewzring/9f7bbfd102003963f9be7dbcf7d40e51) for other syntax highlighting methods.*
diff --git a/pydis_site/apps/content/resources/guides/python-guides/discordpy-subclassing-context.md b/pydis_site/apps/content/resources/guides/python-guides/discordpy-subclassing-context.md
new file mode 100644
index 00000000..b77cb0f9
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/python-guides/discordpy-subclassing-context.md
@@ -0,0 +1,129 @@
+---
+title: Subclassing Context in discord.py
+description: "Subclassing the default `commands.Context` class to add more functionability and customizability."
+---
+
+Start by reading the guide on [subclassing the `Bot` class](./subclassing_bot.md). A subclass of Bot has to be used to
+inject your custom context subclass into discord.py.
+
+## Overview
+
+The way this works is by creating a subclass of discord.py's [`Context` class](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context)
+adding whatever functionality you wish. Usually this is done by adding custom methods or properties, so that you don't need to
+copy it around or awkwardly import it elsewhere.
+
+This guide will show you how to add a `prompt()` method to the context and how to use it in a command.
+
+## Example subclass and code
+
+The first part - of course - is creating the actual context subclass. This is done similarly to creating a bot
+subclass, it will look like this:
+
+```python
+import asyncio
+from typing import Optional
+
+from discord import RawReactionActionEvent
+from discord.ext import commands
+
+
+class CustomContext(commands.Context):
+ async def prompt(
+ self,
+ message: str,
+ *,
+ timeout=30.0,
+ delete_after=True
+ ) -> Optional[bool]:
+ """Prompt the author with an interactive confirmation message.
+
+ This method will send the `message` content, and wait for max `timeout` seconds
+ (default is `30`) for the author to react to the message.
+
+ If `delete_after` is `True`, the message will be deleted before returning a
+ True, False, or None indicating whether the author confirmed, denied,
+ or didn't interact with the message.
+ """
+ msg = await self.send(message)
+
+ for reaction in ('✅', '❌'):
+ await msg.add_reaction(reaction)
+
+ confirmation = None
+
+ # This function is a closure because it is defined inside of another
+ # function. This allows the function to access the self and msg
+ # variables defined above.
+
+ def check(payload: RawReactionActionEvent):
+ # 'nonlocal' works almost like 'global' except for functions inside of
+ # functions. This means that when 'confirmation' is changed, that will
+ # apply to the variable above
+ nonlocal confirmation
+
+ if payload.message_id != msg.id or payload.user_id != self.author.id:
+ return False
+
+ emoji = str(payload.emoji)
+
+ if emoji == '✅':
+ confirmation = True
+ return True
+
+ elif emoji == '❌':
+ confirmation = False
+ return True
+
+ # This means that it was neither of the two emojis added, so the author
+ # added some other unrelated reaction.
+ return False
+
+ try:
+ await self.bot.wait_for('raw_reaction_add', check=check, timeout=timeout)
+ except asyncio.TimeoutError:
+ # The 'confirmation' variable is still None in this case
+ pass
+
+ if delete_after:
+ await msg.delete()
+
+ return confirmation
+```
+
+After creating your context subclass, you need to override the `get_context()` method on your
+Bot class and change the default of the `cls` parameter to this subclass:
+
+```python
+from discord.ext import commands
+
+
+class CustomBot(commands.Bot):
+ async def get_context(self, message, *, cls=CustomContext): # From the above codeblock
+ return await super().get_context(message, cls=cls)
+```
+
+Now that discord.py is using your custom context, you can use it in a command. For example:
+
+```python
+import discord
+from discord.ext import commands
+
+# Enable the message intent so that we get message content. This is needed for
+# the commands we define below
+intents = discord.Intents.default()
+intents.message_content = True
+
+
+# Replace '...' with any additional arguments for the bot
+bot = CustomBot(intents=intents, ...)
+
+
+async def massban(ctx: CustomContext, members: commands.Greedy[discord.Member]):
+ prompt = await ctx.prompt(f"Are you sure you want to ban {len(members)} members?")
+ if not prompt:
+ # Return if the author cancelled, or didn't react in time
+ return
+
+ ... # Perform the mass-ban, knowing the author has confirmed this action
+```
diff --git a/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
index 9d523b4b..92eb52a3 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md
@@ -11,7 +11,7 @@ To help prevent leaking your token,
you should ensure that you don't upload it to an open source program/website,
such as replit and github, as they show your code publicly.
The best practice for storing tokens is generally utilising .env files
-([click here](https://vcokltfre.dev/tips/tokens/.) for more information on storing tokens safely).
+([click here](https://tutorial.vco.sh/tips/tokens/) for more information on storing tokens safely).
# What should I do if my token does get leaked?
diff --git a/pydis_site/apps/content/resources/guides/python-guides/why-not-json-as-database.md b/pydis_site/apps/content/resources/guides/python-guides/why-not-json-as-database.md
index ae34c2b4..6d9f433e 100644
--- a/pydis_site/apps/content/resources/guides/python-guides/why-not-json-as-database.md
+++ b/pydis_site/apps/content/resources/guides/python-guides/why-not-json-as-database.md
@@ -2,7 +2,7 @@
title: Why JSON is unsuitable as a database
description: The many reasons why you shouldn't use JSON as a database, and instead opt for SQL.
relevant_links:
- Tips on Storing Data: https://tutorial.vcokltfre.dev/tips/storage/
+ Tips on Storing Data: https://tutorial.vco.sh/tips/storage/
---
JSON, quite simply, is not a database. It's not designed to be a data storage format,
diff --git a/pydis_site/apps/content/resources/rules.md b/pydis_site/apps/content/resources/rules.md
index b788c81b..803c8041 100644
--- a/pydis_site/apps/content/resources/rules.md
+++ b/pydis_site/apps/content/resources/rules.md
@@ -14,6 +14,7 @@ We have a small but strict set of rules on our server. Please read over them and
> 7. Keep discussions relevant to the channel topic. Each channel's description tells you the topic.
> 8. Do not help with ongoing exams. When helping with homework, help people learn how to do the assignment without doing it for them.
> 9. Do not offer or ask for paid work of any kind.
+> 10. Do not copy and paste answers from ChatGPT or similar AI tools.
# Name & Profile Policy
diff --git a/pydis_site/apps/content/resources/server-info/roles.md b/pydis_site/apps/content/resources/server-info/roles.md
index 409e037e..dc4240d6 100644
--- a/pydis_site/apps/content/resources/server-info/roles.md
+++ b/pydis_site/apps/content/resources/server-info/roles.md
@@ -125,9 +125,7 @@ Being a helper is also more than just quantity of messages, it's about quality.
# Miscellaneous Roles
### <span class="fas fa-circle" style="color:#9f3fee"></span> Partners
-**Description:** Representatives of communities we are partnered with. For a list of partnered communities, see the `#partners` channel.
-
-*Note: Not related to [Discord Partners](https://discordapp.com/partners), which our server is currently a part of.*
+**Description:** Representatives of communities we are partnered with.
### <span class="fas fa-circle" style="color:#c77cfa"></span> Python Community
**Description:** Prominent people in the Python ecosystem.
diff --git a/pydis_site/apps/content/tests/helpers.py b/pydis_site/apps/content/tests/helpers.py
index d897c024..fad91050 100644
--- a/pydis_site/apps/content/tests/helpers.py
+++ b/pydis_site/apps/content/tests/helpers.py
@@ -1,12 +1,13 @@
+import atexit
+import shutil
+import tempfile
from pathlib import Path
-from pyfakefs import fake_filesystem_unittest
+from django.test import TestCase
-# Set the module constant within Patcher to use the fake filesystem
-# https://jmcgeheeiv.github.io/pyfakefs/master/usage.html#modules-to-reload
-with fake_filesystem_unittest.Patcher() as _:
- BASE_PATH = Path("res")
+BASE_PATH = Path(tempfile.mkdtemp(prefix='pydis-site-content-app-tests-'))
+atexit.register(shutil.rmtree, BASE_PATH, ignore_errors=True)
# Valid markdown content with YAML metadata
@@ -50,7 +51,7 @@ PARSED_METADATA = {
PARSED_CATEGORY_INFO = {"title": "Category Name", "description": "Description"}
-class MockPagesTestCase(fake_filesystem_unittest.TestCase):
+class MockPagesTestCase(TestCase):
"""
TestCase with a fake filesystem for testing.
@@ -75,29 +76,27 @@ class MockPagesTestCase(fake_filesystem_unittest.TestCase):
def setUp(self):
"""Create the fake filesystem."""
- self.setUpPyfakefs()
-
- self.fs.create_file(f"{BASE_PATH}/_info.yml", contents=CATEGORY_INFO)
- self.fs.create_file(f"{BASE_PATH}/root.md", contents=MARKDOWN_WITH_METADATA)
- self.fs.create_file(
- f"{BASE_PATH}/root_without_metadata.md", contents=MARKDOWN_WITHOUT_METADATA
- )
- self.fs.create_file(f"{BASE_PATH}/not_a_page.md/_info.yml", contents=CATEGORY_INFO)
- self.fs.create_file(f"{BASE_PATH}/category/_info.yml", contents=CATEGORY_INFO)
- self.fs.create_file(
- f"{BASE_PATH}/category/with_metadata.md", contents=MARKDOWN_WITH_METADATA
- )
- self.fs.create_file(f"{BASE_PATH}/category/subcategory/_info.yml", contents=CATEGORY_INFO)
- self.fs.create_file(
- f"{BASE_PATH}/category/subcategory/with_metadata.md", contents=MARKDOWN_WITH_METADATA
- )
- self.fs.create_file(
- f"{BASE_PATH}/category/subcategory/without_metadata.md",
- contents=MARKDOWN_WITHOUT_METADATA
- )
+ Path(f"{BASE_PATH}/_info.yml").write_text(CATEGORY_INFO)
+ Path(f"{BASE_PATH}/root.md").write_text(MARKDOWN_WITH_METADATA)
+ Path(f"{BASE_PATH}/root_without_metadata.md").write_text(MARKDOWN_WITHOUT_METADATA)
+ Path(f"{BASE_PATH}/not_a_page.md").mkdir(exist_ok=True)
+ Path(f"{BASE_PATH}/not_a_page.md/_info.yml").write_text(CATEGORY_INFO)
+ Path(f"{BASE_PATH}/category").mkdir(exist_ok=True)
+ Path(f"{BASE_PATH}/category/_info.yml").write_text(CATEGORY_INFO)
+ Path(f"{BASE_PATH}/category/with_metadata.md").write_text(MARKDOWN_WITH_METADATA)
+ Path(f"{BASE_PATH}/category/subcategory").mkdir(exist_ok=True)
+ Path(f"{BASE_PATH}/category/subcategory/_info.yml").write_text(CATEGORY_INFO)
+ Path(
+ f"{BASE_PATH}/category/subcategory/with_metadata.md"
+ ).write_text(MARKDOWN_WITH_METADATA)
+ Path(
+ f"{BASE_PATH}/category/subcategory/without_metadata.md"
+ ).write_text(MARKDOWN_WITHOUT_METADATA)
temp = f"{BASE_PATH}/tmp" # noqa: S108
- self.fs.create_file(f"{temp}/_info.yml", contents=CATEGORY_INFO)
- self.fs.create_file(f"{temp}.md", contents=MARKDOWN_WITH_METADATA)
- self.fs.create_file(f"{temp}/category/_info.yml", contents=CATEGORY_INFO)
- self.fs.create_dir(f"{temp}/category/subcategory_without_info")
+ Path(f"{temp}").mkdir(exist_ok=True)
+ Path(f"{temp}/_info.yml").write_text(CATEGORY_INFO)
+ Path(f"{temp}.md").write_text(MARKDOWN_WITH_METADATA)
+ Path(f"{temp}/category").mkdir(exist_ok=True)
+ Path(f"{temp}/category/_info.yml").write_text(CATEGORY_INFO)
+ Path(f"{temp}/category/subcategory_without_info").mkdir(exist_ok=True)
diff --git a/pydis_site/apps/events/README.md b/pydis_site/apps/events/README.md
new file mode 100644
index 00000000..f0d20510
--- /dev/null
+++ b/pydis_site/apps/events/README.md
@@ -0,0 +1,19 @@
+# The "events" app
+
+This application serves mostly static pages that showcase events we run on our
+community. You most likely want to look at the [templates
+directory](../../templates/events) for this app if you want to change anything.
+
+## Directory structure
+
+This app has a relatively minimal structure:
+
+- `migrations` is empty as we don't work with any models here.
+
+- `tests` contains a few tests to make sure that serving our events pages works.
+
+- `views` contains Django views that concern themselves with looking up the
+ matching Django template.
+
+The actual content lives in the [templates directory two layers
+up](../../templates/events).
diff --git a/pydis_site/apps/home/README.md b/pydis_site/apps/home/README.md
new file mode 100644
index 00000000..34c1e367
--- /dev/null
+++ b/pydis_site/apps/home/README.md
@@ -0,0 +1,35 @@
+# The "home" app
+
+This Django application takes care of serving the homepage of our website, that
+is, the first page that you see when you open pythondiscord.com. It also
+manages the timeline page showcasing the history of our community.
+
+## Directory structure
+
+- `migrations` is the standard Django migrations folder. As with [the API
+ app](../api/README.md), you usually won't need to edit this manually, use
+ `python manage.py makemigrations [-n short_description]` to create a new
+ migration here.
+
+- `templatetags` contains custom [template tags and
+ filters](https://docs.djangoproject.com/en/dev/howto/custom-template-tags/)
+ used in the home app.
+
+- `tests` contains unit tests that validate the home app works as expected. If
+ you're looking for guidance in writing tests, the [Django tutorial
+ introducing automated
+ testing](https://docs.djangoproject.com/en/dev/intro/tutorial05/) is a great
+ starting point.
+
+As for the Python modules residing directly in here:
+
+- `models.py` contains our Django model definitions for this app. As this app
+ is rather minimal, this is kept as a single module - more models would be
+ split up into a subfolder as in the other apps.
+
+- `urls.py` configures Django's [URL
+ dispatcher](https://docs.djangoproject.com/en/dev/topics/http/urls/) for our
+ home endpoints.
+
+- `views.py` contains our Django views. You can see where they are linked in the
+ URL dispatcher.
diff --git a/pydis_site/apps/home/models/repository_metadata.py b/pydis_site/apps/home/models.py
index 00a83cd7..00a83cd7 100644
--- a/pydis_site/apps/home/models/repository_metadata.py
+++ b/pydis_site/apps/home/models.py
diff --git a/pydis_site/apps/home/models/__init__.py b/pydis_site/apps/home/models/__init__.py
deleted file mode 100644
index 6c68df9c..00000000
--- a/pydis_site/apps/home/models/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .repository_metadata import RepositoryMetadata
-
-__all__ = ["RepositoryMetadata"]
diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views.py
index 8a165682..8a165682 100644
--- a/pydis_site/apps/home/views/home.py
+++ b/pydis_site/apps/home/views.py
diff --git a/pydis_site/apps/home/views/__init__.py b/pydis_site/apps/home/views/__init__.py
deleted file mode 100644
index 28cc4d65..00000000
--- a/pydis_site/apps/home/views/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .home import HomeView, timeline
-
-__all__ = ["HomeView", "timeline"]
diff --git a/pydis_site/apps/redirect/README.md b/pydis_site/apps/redirect/README.md
new file mode 100644
index 00000000..0d3c1e33
--- /dev/null
+++ b/pydis_site/apps/redirect/README.md
@@ -0,0 +1,13 @@
+# The "redirect" app
+
+This Django application manages redirects on our website. The main magic
+happens in `urls.py`, which transforms our redirects as configured in
+`redirects.yaml` into Django URL routing rules. `tests.py` on the other hand
+simply checks that all redirects configured in `redirects.yaml` work as
+expected.
+
+As suggested by the comment in `redirects.yaml`, this app is mainly here for
+backwards compatibility for our old dewikification project. It is unlikely you
+need to edit it directly. If you did find a reason to perform changes here,
+please [open an
+issue](https://github.com/python-discord/site/issues/new/choose)!
diff --git a/pydis_site/apps/resources/resources/python_org.yaml b/pydis_site/apps/resources/resources/python_org.yaml
new file mode 100644
index 00000000..ece954dd
--- /dev/null
+++ b/pydis_site/apps/resources/resources/python_org.yaml
@@ -0,0 +1,14 @@
+name: The Python Tutorial
+description: The official Python tutorial by Python.org
+title_image: https://www.python.org/static/community_logos/python-logo-master-v3-TM.png
+title_url: https://docs.python.org/3/tutorial/
+tags:
+ topics:
+ - general
+ payment_tiers:
+ - free
+ difficulty:
+ - beginner
+ - intermediate
+ type:
+ - tutorial
diff --git a/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml b/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml
index 12f2a154..482cdf91 100644
--- a/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml
+++ b/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml
@@ -2,7 +2,7 @@ description: This tutorial, written by vcokltfre,
will walk you through all the aspects of creating your own Discord bot,
starting from creating the bot user itself.
name: vcokltfre's Discord Bot Tutorial
-title_url: https://tutorial.vcokltfre.dev/
+title_url: https://tutorial.vco.sh/
tags:
topics:
- discord bots