aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site
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
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')
-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
-rw-r--r--pydis_site/static/images/content/discord_colored_messages/ansi-colors.pngbin43004 -> 100261 bytes
-rw-r--r--pydis_site/static/images/content/discord_colored_messages/result.pngbin13740 -> 9608 bytes
-rw-r--r--pydis_site/templates/events/index.html3
-rw-r--r--pydis_site/templates/events/sidebar/events-list.html10
-rw-r--r--pydis_site/templates/home/timeline.html603
48 files changed, 2430 insertions, 664 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
diff --git a/pydis_site/static/images/content/discord_colored_messages/ansi-colors.png b/pydis_site/static/images/content/discord_colored_messages/ansi-colors.png
index d7176393..d980ab4c 100644
--- a/pydis_site/static/images/content/discord_colored_messages/ansi-colors.png
+++ b/pydis_site/static/images/content/discord_colored_messages/ansi-colors.png
Binary files differ
diff --git a/pydis_site/static/images/content/discord_colored_messages/result.png b/pydis_site/static/images/content/discord_colored_messages/result.png
index a666804e..41ed555c 100644
--- a/pydis_site/static/images/content/discord_colored_messages/result.png
+++ b/pydis_site/static/images/content/discord_colored_messages/result.png
Binary files differ
diff --git a/pydis_site/templates/events/index.html b/pydis_site/templates/events/index.html
index 640682d0..796a2e34 100644
--- a/pydis_site/templates/events/index.html
+++ b/pydis_site/templates/events/index.html
@@ -9,9 +9,6 @@
{% block event_content %}
<div class="box">
<h2 class="title is-4"><a href="{% url "events:page" path="code-jams" %}">Code Jams</a></h2>
- <div class="notification is-success">
- <a href="{% url "events:page" path="code-jams/9" %}">The <b>2022 Summer Code Jam</b> is underway!</a>.
- </div>
<p>Every year we hold a community-wide Summer Code Jam. For this event, members of our community are assigned to teams to collaborate and create something amazing using a technology we picked for them. One such technology that was picked for the Summer 2021 Code Jam was text user interfaces (TUIs), where teams could pick from a pre-approved list of frameworks.</p>
<p>To help fuel the creative process, we provide a specific theme, like <strong>Think Inside the Box</strong> or <strong>Early Internet</strong>. At the end of the Code Jam, the projects are judged by Python Discord server staff members and guest judges from the larger Python community. The judges will consider creativity, code quality, teamwork, and adherence to the theme.</p>
<p>If you want to read more about Code Jams, visit our <a href="{% url "events:page" path="code-jams" %}">Code Jam info page</a> or watch this video showcasing the best projects created during the <strong>Winter Code Jam 2020: Ancient Technology</strong>:</p>
diff --git a/pydis_site/templates/events/sidebar/events-list.html b/pydis_site/templates/events/sidebar/events-list.html
index 8deac80e..819fb485 100644
--- a/pydis_site/templates/events/sidebar/events-list.html
+++ b/pydis_site/templates/events/sidebar/events-list.html
@@ -1,10 +1,10 @@
<div class="box">
- <p class="menu-label">Event Calendar 2022</p>
+ <p class="menu-label">Event Calendar 2023</p>
<ul class="menu-list">
- <li><a class="has-text-link" href="https://pyweek.org/33/" target="_blank" rel="noopener">March: PyWeek 33</a></li>
- <li><a class="has-text-link" href="{% url "events:page" path="code-jams/9" %}">July: Summer Code Jam</a></li>
- <li><a class="has-text-link" href="https://pyweek.org/34/" target="_blank" rel="noopener">September: PyWeek 34</a></li>
- <li><a class="has-text-black" style="cursor: default;">October: Pixels</a></li>
+ <li><a class="has-text-black" style="cursor: default;">March: PyWeek 35</a></li>
+ <li><a class="has-text-black" style="cursor: default;">TBD: Summer Code Jam</a></li>
+ <li><a class="has-text-black" style="cursor: default;">September: PyWeek 36</a></li>
+ <li><a class="has-text-black" style="cursor: default;">TBD: Pixels</a></li>
<li><a class="has-text-black" style="cursor: default;">December: Advent of Code</a></li>
</ul>
</div>
diff --git a/pydis_site/templates/home/timeline.html b/pydis_site/templates/home/timeline.html
index b404d6c0..8b152f61 100644
--- a/pydis_site/templates/home/timeline.html
+++ b/pydis_site/templates/home/timeline.html
@@ -13,52 +13,93 @@
<section class="cd-timeline js-cd-timeline">
<div class="container max-width-lg cd-timeline__container">
<div class="cd-timeline__block">
- <div class="cd-timeline__img cd-timeline__img--picture">
- <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture">
+ <div class="cd-timeline__img pastel-red cd-timeline__img--picture">
+ <i class="fa fa-youtube-play"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>Python Discord is created</h2>
- <p class="color-contrast-medium"><strong>Joe Banks</strong> becomes one of the owners around 3 days after it
- is created, and <strong>Leon Sandøy</strong> (lemon) joins the owner team later in the year, when the community
- has around 300 members.</p>
+ <h2>Summer Code Jam 2020 Highlights</h2>
+ <p class="color-contrast-medium">
+ We release a new video to our YouTube showing the best projects from the Summer Code Jam 2020.
+ Better late than never!
+ </p>
+
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/g9cnp4W0P54" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Jan 8th, 2017</span>
+ <span class="cd-timeline__date">Mar 21st, 2021</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
- <i class="fa fa-users"></i>
+ <div class="cd-timeline__img pastel-purple cd-timeline__img--picture">
+ <i class="fa fa-comment"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>Python Discord hits 1,000 members</h2>
- <p class="color-contrast-medium">Our main source of new users at this point is a post on Reddit that
- happens to get very good SEO. We are one of the top 10 search engine hits for the search term
- "python discord".</p>
+ <h2>New feature: Weekly discussion channel</h2>
+ <p class="color-contrast-medium">Every week (or two weeks), we'll be posting a new topic to discuss in a
+ channel called <b>#weekly-topic-discussion</b>. Our inaugural topic is a PyCon talk by Anthony Shaw called
+ <b>Wily Python: Writing simpler and more maintainable Python.</b></a>.
+ </p>
+
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/dqdsNoApJ80" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Nov 10th, 2017</span>
+ <span class="cd-timeline__date">Mar 13th, 2021</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img cd-timeline__img--picture">
- <img src={% static "images/timeline/cd-icon-picture.svg" %} alt="Picture">
+ <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
+ <i class="fa fa-microphone"></i>
</div>
- <div class="cd-timeline__content text-component">
- <h2>Our logo is born. Thanks @Aperture!</h2>
- <p class="pydis-logo-banner"><img
- src="https://raw.githubusercontent.com/python-discord/branding/main/logos/logo_banner/logo_site_banner.svg">
- </p>
+ <div class="cd-timeline__content text-component">
+ <h2>We're on the Teaching Python podcast!</h2>
+ <p class="color-contrast-medium">Leon joins Sean and Kelly on the Teaching Python podcast to discuss how the pandemic has
+ changed the way we learn, and what role communities like Python Discord can play in this new world.
+ You can find the episode <a href="https://teachingpython.fm/63"> at teachingpython.fm</a>.
+ </p>
+
+ <iframe width="100%" height="166" frameborder="0" scrolling="no"
+ src="https://player.fireside.fm/v2/UIYXtbeL+qOjGAsKi?theme=dark"
+ ></iframe>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Feb 3rd, 2018</span>
+ <span class="cd-timeline__date">Mar 13th, 2021</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-green cd-timeline__img--picture">
+ <i class="fa fa-microphone"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Leon Sandøy appears on Talk Python To Me</h2>
+ <p class="color-contrast-medium">Leon goes on the Talk Python to Me podcast with Michael Kennedy
+ to discuss the history of Python Discord, the critical importance of culture, and how to run a massive
+ community. You can find the episode <a href="https://talkpython.fm/episodes/show/305/python-community-at-python-discord"> at talkpython.fm</a>.
+ </p>
+
+ <iframe width="100%" height="166" scrolling="no" frameborder="no"
+ src="https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/996083146&color=ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false">
+ </iframe>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Mar 1st, 2021</span>
</div>
</div>
</div>
@@ -69,71 +110,72 @@
</div>
<div class="cd-timeline__content text-component">
- <h2>PyDis hits 2,000 members; pythondiscord.com and @Python are live</h2>
- <p class="color-contrast-medium">The public moderation bot we're using at the time, Rowboat, announces
- it will be shutting down. We decide that we'll write our own bot to handle moderation, so that we
- can have more control over its features. We also buy a domain and start making a website in Flask.
- </p>
+ <h2>We now have 150,000 members!</h2>
+ <p class="color-contrast-medium">Our growth continues to accelerate.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Mar 4th, 2018</span>
+ <span class="cd-timeline__date">Feb 18th, 2021</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
<div class="cd-timeline__img pastel-blue cd-timeline__img--picture">
- <i class="fa fa-dice"></i>
+ <i class="fa fa-music"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>First code jam with the theme “snakes”</h2>
- <p class="color-contrast-medium">Our very first Code Jam attracts a handful of users who work in random
- teams of 2. We ask our participants to write a snake-themed Discord bot. Most of the code written
- for this jam still lives on in Sir Lancebot, and you can play with it by using the
- <code>.snakes</code> command. For more information on this event, see <a
- href="https://pythondiscord.com/pages/code-jams/code-jam-1-snakes-bot/">the event page</a></p>
+ <h2>We release The PEP 8 song</h2>
+ <p class="color-contrast-medium">We release the PEP 8 song on our YouTube channel, which finds tens of
+ thousands of listeners!</p>
+
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/hgI0p1zf31k" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Mar 23rd, 2018</span>
+ <span class="cd-timeline__date">February 8th, 2021</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-lime cd-timeline__img--picture">
- <i class="fa fa-scroll"></i>
+ <div class="cd-timeline__img pastel-red cd-timeline__img--picture">
+ <i class="fa fa-snowflake-o"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>The privacy policy is created</h2>
- <p class="color-contrast-medium">Since data privacy is quite important to us, we create a privacy page
- pretty much as soon as our new bot and site starts collecting some data. To this day, we keep <a
- href="https://pythondiscord.com/pages/privacy/">our privacy policy</a> up to date with all
- changes, and since April 2020 we've started doing <a
- href="https://pythondiscord.notion.site/6784e3a9752444e89d19e65fd4510d8d">monthly data reviews</a>.</p>
+ <h2>Advent of Code attracts hundreds of participants</h2>
+ <p class="color-contrast-medium">
+ A total of 443 Python Discord members sign up to be part of
+ <a href="https://adventofcode.com/">Eric Wastl's excellent Advent of Code event</a>.
+ As always, we provide dedicated announcements, scoreboards, bot commands and channels for our members
+ to enjoy the event in.
+
+ </p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">May 21st, 2018</span>
+ <span class="cd-timeline__date">December 1st - 25th, 2020</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
- <i class="fa fa-handshake"></i>
+ <div class="cd-timeline__img pastel-orange cd-timeline__img--picture">
+ <i class="fa fa-wrench"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>Do You Even Python and PyDis merger</h2>
- <p class="color-contrast-medium">At this point in time, there are only two serious Python communities on
- Discord - Ours, and one called Do You Even Python. We approach the owners of DYEP with a bold
- proposal - let's shut down their community, replace it with links to ours, and in return we will let
- their staff join our staff. This gives us a big boost in members, and eventually leads to @eivl and
- @Mr. Hemlock joining our Admin team</p>
+ <h2>We migrate all our infrastructure to Kubernetes</h2>
+ <p class="color-contrast-medium">As our tech stack grows, we decide to migrate all our services over to a
+ container orchestration paradigm via Kubernetes. This gives us better control and scalability.
+ <b>Joe Banks</b> takes on the role as DevOps Lead.
+ </p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Jun 9th, 2018</span>
+ <span class="cd-timeline__date">Nov 29th, 2020</span>
</div>
</div>
</div>
@@ -144,167 +186,181 @@
</div>
<div class="cd-timeline__content text-component">
- <h2>PyDis hits 5,000 members and partners with r/Python</h2>
- <p class="color-contrast-medium">As we continue to grow, we approach the r/Python subreddit and ask to
- become their official Discord community. They agree, and we become listed in their sidebar, giving
- us yet another source of new members.</p>
+ <h2>Python Discord hits 100,000 members!</h2>
+ <p class="color-contrast-medium">Only six months after hitting 40,000 users, we hit 100,000 users. A
+ monumental milestone,
+ and one we're very proud of. To commemorate it, we create this timeline.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Jun 20th, 2018</span>
+ <span class="cd-timeline__date">Oct 22nd, 2020</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
- <i class="fa fa-handshake"></i>
+ <div class="cd-timeline__img cd-timeline__img--picture">
+ <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture">
</div>
<div class="cd-timeline__content text-component">
- <h2>PyDis is now partnered with Discord; the vanity URL discord.gg/python is created</h2>
- <p class="color-contrast-medium">After being rejected for their Partner program several times, we
- finally get approved. The recent partnership with the r/Python subreddit plays a significant role in
- qualifying us for this partnership.</p>
+ <h2>Python Discord hosts the 2020 CPython Core Developer Q&A</h2>
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/gXMdfBTcOfQ" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Jul 10th, 2018</span>
+ <span class="cd-timeline__date">Oct 21st, 2020</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-blue cd-timeline__img--picture">
- <i class="fa fa-dice"></i>
+ <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
+ <i class="fa fa-handshake"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>First Hacktoberfest PyDis event; @Sir Lancebot is created</h2>
- <p class="color-contrast-medium">We create a second bot for our community and fill it up with simple,
- fun and relatively easy issues. The idea is to create an approachable arena for our members to cut
- their open-source teeth on, and to provide lots of help and hand-holding for those who get stuck.
- We're training our members to be productive contributors in the open-source ecosystem.</p>
+ <h2>Python Discord is now the new home of the PyWeek event!</h2>
+ <p class="color-contrast-medium">PyWeek, a game jam that has been running since 2005, joins Python
+ Discord as one of our official events. Find more information about PyWeek on <a
+ href="https://pyweek.org/">their official website</a>.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Oct 1st, 2018</span>
+ <span class="cd-timeline__date">Aug 16th, 2020</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
- <i class="fa fa-users"></i>
+ <div class="cd-timeline__img pastel-blue cd-timeline__img--picture">
+ <i class="fa fa-dice"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>PyDis hits 10,000 members</h2>
- <p class="color-contrast-medium">We partner with RLBot, move from GitLab to GitHub, and start putting
- together the first Advent of Code event.</p>
+ <h2>PyDis summer code jam 2020 with the theme “Early Internet” and Django as the technology</h2>
+ <p class="color-contrast-medium">Sponsored by the Django Software Foundation and JetBrains, the Summer
+ Code Jam for 2020 attracts hundreds of participants, and sees the creation of some fantastic
+ projects. Check them out in our judge stream below:</p>
+
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/OFtm8f2iu6c" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Nov 24th, 2018</span>
+ <span class="cd-timeline__date">Jul 31st, 2020</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-orange cd-timeline__img--picture">
- <i class="fa fa-code"></i>
+ <div class="cd-timeline__img pastel-red cd-timeline__img--picture">
+ <i class="fa fa-chart-bar"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>django-simple-bulma is released on PyPi</h2>
- <p class="color-contrast-medium">Our very first package on PyPI, <a
- href="https://pypi.org/project/django-simple-bulma/">django-simple-bulma</a> is a package that
- sets up the Bulma CSS framework for your Django application and lets you configure everything in
- settings.py.</p>
+ <h2>Python Discord Public Statistics are now live</h2>
+ <p class="color-contrast-medium">After getting numerous requests to publish beautiful data on member
+ count and channel use, we create <a href="https://stats.pythondiscord.com/">stats.pythondiscord.com</a> for
+ all to enjoy.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Dec 19th, 2018</span>
+ <span class="cd-timeline__date">Jun 4th, 2020</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
- <i class="fa fa-users"></i>
+ <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
+ <i class="fa fa-handshake"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>PyDis hits 15,000 members; the “hot ones special” video is released</h2>
- <div class="force-aspect-container">
- <iframe class="force-aspect-content" src="https://www.youtube.com/embed/DIBXg8Qh7bA" frameborder="0"
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
- allowfullscreen></iframe>
- </div>
+ <h2>Python Discord is now listed on python.org/community</h2>
+ <p class="color-contrast-medium">After working towards this goal for months, we finally work out an
+ arrangement with the PSF that allows us to be listed on that most holiest of websites:
+ https://python.org/. <a href="https://youtu.be/yciX2meIkXI?t=3">There was much rejoicing.</a></p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Apr 8th, 2019</span>
+ <span class="cd-timeline__date">May 28th, 2020</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-orange cd-timeline__img--picture">
- <i class="fa fa-code"></i>
+ <div class="cd-timeline__img pastel-green cd-timeline__img--picture">
+ <i class="fa fa-comments"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>The Django rewrite of pythondiscord.com is now live!</h2>
- <p class="color-contrast-medium">The site is getting more and more complex, and it's time for a rewrite.
- We decide to go for a different stack, and build a website based on Django, DRF, Bulma and
- PostgreSQL.</p>
+ <h2>ModMail is now live</h2>
+ <p class="color-contrast-medium">Having originally planned to write our own ModMail bot from scratch, we
+ come across an exceptionally good <a href="https://github.com/kyb3r/modmail">ModMail bot by
+ kyb3r</a> and decide to just self-host that one instead.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Sep 15, 2019</span>
+ <span class="cd-timeline__date">May 25th, 2020</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-lime cd-timeline__img--picture">
- <i class="fa fa-scroll"></i>
+ <div class="cd-timeline__img pastel-purple cd-timeline__img--picture">
+ <i class="fa fa-gamepad"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>The code of conduct is created</h2>
- <p class="color-contrast-medium">Inspired by the Adafruit, Rust and Django communities, an essential
- community pillar is created; Our <a href="https://pythondiscord.com/pages/code-of-conduct/">Code of
- Conduct.</a></p>
+ <h2>PyDis Game Jam 2020 with the “Three of a Kind” theme and Arcade as the technology</h2>
+ <p class="color-contrast-medium">The creator of Arcade, Paul Vincent Craven, joins us as a judge.
+ Several of the Code Jam participants also end up getting involved contributing to the Arcade
+ repository.</p>
+
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/KkLXMvKfEgs" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Oct 26th, 2019</span>
+ <span class="cd-timeline__date">Apr 17th, 2020</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img cd-timeline__img--picture">
- <img src={% static "images/timeline/cd-icon-picture.svg" %} alt="Picture">
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>Sebastiaan Zeef becomes an owner</h2>
- <p class="color-contrast-medium">After being a long time active contributor to our projects and the driving
- force behind many of our events, Sebastiaan Zeef joins the Owners Team alongside Joe & Leon.</p>
+ <h2>Python Discord hits 40,000 members, and is now bigger than Liechtenstein.</h2>
+ <p class="color-contrast-medium"><img
+ src="https://cdn.discordapp.com/attachments/354619224620138496/699666518476324954/unknown.png">
+ </p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Sept 22nd, 2019</span>
+ <span class="cd-timeline__date">Apr 14, 2020</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
- <i class="fa fa-users"></i>
+ <div class="cd-timeline__img pastel-green cd-timeline__img--picture">
+ <i class="fa fa-comments"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>PyDis hits 30,000 members</h2>
- <p class="color-contrast-medium">More than tripling in size since the year before, the community hits
- 30000 users. At this point, we're probably the largest Python chat community on the planet.</p>
+ <h2>The new help channel system is live</h2>
+ <p class="color-contrast-medium">We release our dynamic help-channel system, which allows you to claim
+ your very own help channel instead of fighting over the static help channels. We release a <a
+ href="https://pythondiscord.com/pages/resources/guides/help-channels/">Help Channel Guide</a> to
+ help our members fully understand how the system works.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Dec 22nd, 2019</span>
+ <span class="cd-timeline__date">Apr 5th, 2020</span>
</div>
</div>
</div>
@@ -333,169 +389,155 @@
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-green cd-timeline__img--picture">
- <i class="fa fa-comments"></i>
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>The new help channel system is live</h2>
- <p class="color-contrast-medium">We release our dynamic help-channel system, which allows you to claim
- your very own help channel instead of fighting over the static help channels. We release a <a
- href="https://pythondiscord.com/pages/resources/guides/help-channels/">Help Channel Guide</a> to
- help our members fully understand how the system works.</p>
+ <h2>PyDis hits 30,000 members</h2>
+ <p class="color-contrast-medium">More than tripling in size since the year before, the community hits
+ 30000 users. At this point, we're probably the largest Python chat community on the planet.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Apr 5th, 2020</span>
+ <span class="cd-timeline__date">Dec 22nd, 2019</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
- <i class="fa fa-users"></i>
+ <div class="cd-timeline__img cd-timeline__img--picture">
+ <img src={% static "images/timeline/cd-icon-picture.svg" %} alt="Picture">
</div>
<div class="cd-timeline__content text-component">
- <h2>Python Discord hits 40,000 members, and is now bigger than Liechtenstein.</h2>
- <p class="color-contrast-medium"><img
- src="https://cdn.discordapp.com/attachments/354619224620138496/699666518476324954/unknown.png">
- </p>
+ <h2>Sebastiaan Zeef becomes an owner</h2>
+ <p class="color-contrast-medium">After being a long time active contributor to our projects and the driving
+ force behind many of our events, Sebastiaan Zeef joins the Owners Team alongside Joe & Leon.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Apr 14, 2020</span>
+ <span class="cd-timeline__date">Sept 22nd, 2019</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-purple cd-timeline__img--picture">
- <i class="fa fa-gamepad"></i>
+ <div class="cd-timeline__img pastel-lime cd-timeline__img--picture">
+ <i class="fa fa-scroll"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>PyDis Game Jam 2020 with the “Three of a Kind” theme and Arcade as the technology</h2>
- <p class="color-contrast-medium">The creator of Arcade, Paul Vincent Craven, joins us as a judge.
- Several of the Code Jam participants also end up getting involved contributing to the Arcade
- repository.</p>
-
- <div class="force-aspect-container">
- <iframe class="force-aspect-content" src="https://www.youtube.com/embed/KkLXMvKfEgs" frameborder="0"
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
- allowfullscreen></iframe>
- </div>
+ <h2>The code of conduct is created</h2>
+ <p class="color-contrast-medium">Inspired by the Adafruit, Rust and Django communities, an essential
+ community pillar is created; Our <a href="https://pythondiscord.com/pages/code-of-conduct/">Code of
+ Conduct.</a></p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Apr 17th, 2020</span>
+ <span class="cd-timeline__date">Oct 26th, 2019</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-green cd-timeline__img--picture">
- <i class="fa fa-comments"></i>
+ <div class="cd-timeline__img pastel-orange cd-timeline__img--picture">
+ <i class="fa fa-code"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>ModMail is now live</h2>
- <p class="color-contrast-medium">Having originally planned to write our own ModMail bot from scratch, we
- come across an exceptionally good <a href="https://github.com/kyb3r/modmail">ModMail bot by
- kyb3r</a> and decide to just self-host that one instead.</p>
+ <h2>The Django rewrite of pythondiscord.com is now live!</h2>
+ <p class="color-contrast-medium">The site is getting more and more complex, and it's time for a rewrite.
+ We decide to go for a different stack, and build a website based on Django, DRF, Bulma and
+ PostgreSQL.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">May 25th, 2020</span>
+ <span class="cd-timeline__date">Sep 15, 2019</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
- <i class="fa fa-handshake"></i>
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>Python Discord is now listed on python.org/community</h2>
- <p class="color-contrast-medium">After working towards this goal for months, we finally work out an
- arrangement with the PSF that allows us to be listed on that most holiest of websites:
- https://python.org/. <a href="https://youtu.be/yciX2meIkXI?t=3">There was much rejoicing.</a></p>
+ <h2>PyDis hits 15,000 members; the “hot ones special” video is released</h2>
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/DIBXg8Qh7bA" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">May 28th, 2020</span>
+ <span class="cd-timeline__date">Apr 8th, 2019</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-red cd-timeline__img--picture">
- <i class="fa fa-chart-bar"></i>
+ <div class="cd-timeline__img pastel-orange cd-timeline__img--picture">
+ <i class="fa fa-code"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>Python Discord Public Statistics are now live</h2>
- <p class="color-contrast-medium">After getting numerous requests to publish beautiful data on member
- count and channel use, we create <a href="https://stats.pythondiscord.com/">stats.pythondiscord.com</a> for
- all to enjoy.</p>
+ <h2>django-simple-bulma is released on PyPi</h2>
+ <p class="color-contrast-medium">Our very first package on PyPI, <a
+ href="https://pypi.org/project/django-simple-bulma/">django-simple-bulma</a> is a package that
+ sets up the Bulma CSS framework for your Django application and lets you configure everything in
+ settings.py.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Jun 4th, 2020</span>
+ <span class="cd-timeline__date">Dec 19th, 2018</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-blue cd-timeline__img--picture">
- <i class="fa fa-dice"></i>
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>PyDis summer code jam 2020 with the theme “Early Internet” and Django as the technology</h2>
- <p class="color-contrast-medium">Sponsored by the Django Software Foundation and JetBrains, the Summer
- Code Jam for 2020 attracts hundreds of participants, and sees the creation of some fantastic
- projects. Check them out in our judge stream below:</p>
-
- <div class="force-aspect-container">
- <iframe class="force-aspect-content" src="https://www.youtube.com/embed/OFtm8f2iu6c" frameborder="0"
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
- allowfullscreen></iframe>
- </div>
+ <h2>PyDis hits 10,000 members</h2>
+ <p class="color-contrast-medium">We partner with RLBot, move from GitLab to GitHub, and start putting
+ together the first Advent of Code event.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Jul 31st, 2020</span>
+ <span class="cd-timeline__date">Nov 24th, 2018</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
- <i class="fa fa-handshake"></i>
+ <div class="cd-timeline__img pastel-blue cd-timeline__img--picture">
+ <i class="fa fa-dice"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>Python Discord is now the new home of the PyWeek event!</h2>
- <p class="color-contrast-medium">PyWeek, a game jam that has been running since 2005, joins Python
- Discord as one of our official events. Find more information about PyWeek on <a
- href="https://pyweek.org/">their official website</a>.</p>
+ <h2>First Hacktoberfest PyDis event; @Sir Lancebot is created</h2>
+ <p class="color-contrast-medium">We create a second bot for our community and fill it up with simple,
+ fun and relatively easy issues. The idea is to create an approachable arena for our members to cut
+ their open-source teeth on, and to provide lots of help and hand-holding for those who get stuck.
+ We're training our members to be productive contributors in the open-source ecosystem.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Aug 16th, 2020</span>
+ <span class="cd-timeline__date">Oct 1st, 2018</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img cd-timeline__img--picture">
- <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture">
+ <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
+ <i class="fa fa-handshake"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>Python Discord hosts the 2020 CPython Core Developer Q&A</h2>
- <div class="force-aspect-container">
- <iframe class="force-aspect-content" src="https://www.youtube.com/embed/gXMdfBTcOfQ" frameborder="0"
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
- allowfullscreen></iframe>
- </div>
+ <h2>PyDis is now partnered with Discord; the vanity URL discord.gg/python is created</h2>
+ <p class="color-contrast-medium">After being rejected for their Partner program several times, we
+ finally get approved. The recent partnership with the r/Python subreddit plays a significant role in
+ qualifying us for this partnership.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Oct 21st, 2020</span>
+ <span class="cd-timeline__date">Jul 10th, 2018</span>
</div>
</div>
</div>
@@ -506,75 +548,70 @@
</div>
<div class="cd-timeline__content text-component">
- <h2>Python Discord hits 100,000 members!</h2>
- <p class="color-contrast-medium">Only six months after hitting 40,000 users, we hit 100,000 users. A
- monumental milestone,
- and one we're very proud of. To commemorate it, we create this timeline.</p>
+ <h2>PyDis hits 5,000 members and partners with r/Python</h2>
+ <p class="color-contrast-medium">As we continue to grow, we approach the r/Python subreddit and ask to
+ become their official Discord community. They agree, and we become listed in their sidebar, giving
+ us yet another source of new members.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Oct 22nd, 2020</span>
+ <span class="cd-timeline__date">Jun 20th, 2018</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-orange cd-timeline__img--picture">
- <i class="fa fa-wrench"></i>
+ <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
+ <i class="fa fa-handshake"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>We migrate all our infrastructure to Kubernetes</h2>
- <p class="color-contrast-medium">As our tech stack grows, we decide to migrate all our services over to a
- container orchestration paradigm via Kubernetes. This gives us better control and scalability.
- <b>Joe Banks</b> takes on the role as DevOps Lead.
- </p>
+ <h2>Do You Even Python and PyDis merger</h2>
+ <p class="color-contrast-medium">At this point in time, there are only two serious Python communities on
+ Discord - Ours, and one called Do You Even Python. We approach the owners of DYEP with a bold
+ proposal - let's shut down their community, replace it with links to ours, and in return we will let
+ their staff join our staff. This gives us a big boost in members, and eventually leads to @eivl and
+ @Mr. Hemlock joining our Admin team</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Nov 29th, 2020</span>
+ <span class="cd-timeline__date">Jun 9th, 2018</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-red cd-timeline__img--picture">
- <i class="fa fa-snowflake-o"></i>
+ <div class="cd-timeline__img pastel-lime cd-timeline__img--picture">
+ <i class="fa fa-scroll"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>Advent of Code attracts hundreds of participants</h2>
- <p class="color-contrast-medium">
- A total of 443 Python Discord members sign up to be part of
- <a href="https://adventofcode.com/">Eric Wastl's excellent Advent of Code event</a>.
- As always, we provide dedicated announcements, scoreboards, bot commands and channels for our members
- to enjoy the event in.
-
- </p>
+ <h2>The privacy policy is created</h2>
+ <p class="color-contrast-medium">Since data privacy is quite important to us, we create a privacy page
+ pretty much as soon as our new bot and site starts collecting some data. To this day, we keep <a
+ href="https://pythondiscord.com/pages/privacy/">our privacy policy</a> up to date with all
+ changes, and since April 2020 we've started doing <a
+ href="https://pythondiscord.notion.site/6784e3a9752444e89d19e65fd4510d8d">monthly data reviews</a>.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">December 1st - 25th, 2020</span>
+ <span class="cd-timeline__date">May 21st, 2018</span>
</div>
</div>
</div>
-
<div class="cd-timeline__block">
<div class="cd-timeline__img pastel-blue cd-timeline__img--picture">
- <i class="fa fa-music"></i>
+ <i class="fa fa-dice"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>We release The PEP 8 song</h2>
- <p class="color-contrast-medium">We release the PEP 8 song on our YouTube channel, which finds tens of
- thousands of listeners!</p>
-
- <div class="force-aspect-container">
- <iframe class="force-aspect-content" src="https://www.youtube.com/embed/hgI0p1zf31k" frameborder="0"
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
- allowfullscreen></iframe>
- </div>
+ <h2>First code jam with the theme “snakes”</h2>
+ <p class="color-contrast-medium">Our very first Code Jam attracts a handful of users who work in random
+ teams of 2. We ask our participants to write a snake-themed Discord bot. Most of the code written
+ for this jam still lives on in Sir Lancebot, and you can play with it by using the
+ <code>.snakes</code> command. For more information on this event, see <a
+ href="https://pythondiscord.com/pages/code-jams/code-jam-1-snakes-bot/">the event page</a></p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">February 8th, 2021</span>
+ <span class="cd-timeline__date">Mar 23rd, 2018</span>
</div>
</div>
</div>
@@ -585,103 +622,65 @@
</div>
<div class="cd-timeline__content text-component">
- <h2>We now have 150,000 members!</h2>
- <p class="color-contrast-medium">Our growth continues to accelerate.</p>
-
- <div class="flex justify-between items-center">
- <span class="cd-timeline__date">Feb 18th, 2021</span>
- </div>
- </div>
- </div>
-
- <div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-green cd-timeline__img--picture">
- <i class="fa fa-microphone"></i>
- </div>
-
- <div class="cd-timeline__content text-component">
- <h2>Leon Sandøy appears on Talk Python To Me</h2>
- <p class="color-contrast-medium">Leon goes on the Talk Python to Me podcast with Michael Kennedy
- to discuss the history of Python Discord, the critical importance of culture, and how to run a massive
- community. You can find the episode <a href="https://talkpython.fm/episodes/show/305/python-community-at-python-discord"> at talkpython.fm</a>.
+ <h2>PyDis hits 2,000 members; pythondiscord.com and @Python are live</h2>
+ <p class="color-contrast-medium">The public moderation bot we're using at the time, Rowboat, announces
+ it will be shutting down. We decide that we'll write our own bot to handle moderation, so that we
+ can have more control over its features. We also buy a domain and start making a website in Flask.
</p>
- <iframe width="100%" height="166" scrolling="no" frameborder="no"
- src="https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/996083146&color=ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false">
- </iframe>
-
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Mar 1st, 2021</span>
+ <span class="cd-timeline__date">Mar 4th, 2018</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
- <i class="fa fa-microphone"></i>
+ <div class="cd-timeline__img cd-timeline__img--picture">
+ <img src={% static "images/timeline/cd-icon-picture.svg" %} alt="Picture">
</div>
- <div class="cd-timeline__content text-component">
- <h2>We're on the Teaching Python podcast!</h2>
- <p class="color-contrast-medium">Leon joins Sean and Kelly on the Teaching Python podcast to discuss how the pandemic has
- changed the way we learn, and what role communities like Python Discord can play in this new world.
- You can find the episode <a href="https://teachingpython.fm/63"> at teachingpython.fm</a>.
- </p>
-
- <iframe width="100%" height="166" frameborder="0" scrolling="no"
- src="https://player.fireside.fm/v2/UIYXtbeL+qOjGAsKi?theme=dark"
- ></iframe>
+ <div class="cd-timeline__content text-component">
+ <h2>Our logo is born. Thanks @Aperture!</h2>
+ <p class="pydis-logo-banner"><img
+ src="https://raw.githubusercontent.com/python-discord/branding/main/logos/logo_banner/logo_site_banner.svg">
+ </p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Mar 13th, 2021</span>
+ <span class="cd-timeline__date">Feb 3rd, 2018</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-purple cd-timeline__img--picture">
- <i class="fa fa-comment"></i>
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
</div>
<div class="cd-timeline__content text-component">
- <h2>New feature: Weekly discussion channel</h2>
- <p class="color-contrast-medium">Every week (or two weeks), we'll be posting a new topic to discuss in a
- channel called <b>#weekly-topic-discussion</b>. Our inaugural topic is a PyCon talk by Anthony Shaw called
- <b>Wily Python: Writing simpler and more maintainable Python.</b></a>.
- </p>
-
- <div class="force-aspect-container">
- <iframe class="force-aspect-content" src="https://www.youtube.com/embed/dqdsNoApJ80" frameborder="0"
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
- allowfullscreen></iframe>
- </div>
+ <h2>Python Discord hits 1,000 members</h2>
+ <p class="color-contrast-medium">Our main source of new users at this point is a post on Reddit that
+ happens to get very good SEO. We are one of the top 10 search engine hits for the search term
+ "python discord".</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Mar 13th, 2021</span>
+ <span class="cd-timeline__date">Nov 10th, 2017</span>
</div>
</div>
</div>
<div class="cd-timeline__block">
- <div class="cd-timeline__img pastel-red cd-timeline__img--picture">
- <i class="fa fa-youtube-play"></i>
+ <div class="cd-timeline__img cd-timeline__img--picture">
+ <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture">
</div>
<div class="cd-timeline__content text-component">
- <h2>Summer Code Jam 2020 Highlights</h2>
- <p class="color-contrast-medium">
- We release a new video to our YouTube showing the best projects from the Summer Code Jam 2020.
- Better late than never!
- </p>
-
- <div class="force-aspect-container">
- <iframe class="force-aspect-content" src="https://www.youtube.com/embed/g9cnp4W0P54" frameborder="0"
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
- allowfullscreen></iframe>
- </div>
+ <h2>Python Discord is created</h2>
+ <p class="color-contrast-medium"><strong>Joe Banks</strong> becomes one of the owners around 3 days after it
+ is created, and <strong>Leon Sandøy</strong> (lemon) joins the owner team later in the year, when the community
+ has around 300 members.</p>
<div class="flex justify-between items-center">
- <span class="cd-timeline__date">Mar 21st, 2021</span>
+ <span class="cd-timeline__date">Jan 8th, 2017</span>
</div>
</div>
</div>