From 4bf208784e21a97e29474a7b5e7283d1105b14c9 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 12 Nov 2021 09:27:15 +0100 Subject: Content: update help channel claiming system This commit changes the following inside the help channel guide: * Only one help channel can be claimed at the same time * You can use the search to find your channel * The channel will close after 10 minutes if someone else sends a message --- .../apps/content/resources/guides/pydis-guides/help-channel-guide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md index 8b7c5584..fe1c4747 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md @@ -42,14 +42,14 @@ There are always 3 available help channels waiting to be claimed in the **Python In order to claim one, simply start typing your question into one of these channels. Once your question has been posted, you have claimed this channel, and the channel will be moved down to the **Python Help: Occupied** category. -If you're unable to type into these channels, this means you're currently **on cooldown**. In order to prevent someone from claiming all the channels for themselves, **we only allow someone to claim a new help channel every 15 minutes**. However, if you close your help channel using the `!dormant` command, this cooldown is reset early. +If you're unable to type into these channels, this means you're currently **on cooldown**. In order to prevent someone from claiming all the channels for themselves, **we only allow someone to claim a single help channel at the same time**. You can search in the top right corner of your Discord client `from:yourusername#xxxx` to find back your channel. ![Channel available message](/static/images/content/help_channels/available_message.png) *This message is always posted when a channel becomes available for use.* ## Q: For how long is the channel mine? -The channel is yours until it has been inactive for **30 minutes**. When this happens, we move the channel down to the **Python Help: Dormant** category, and make the channel read-only. After a while, the channel will be rotated back into **Python Help: Available** for the next question. Please try to resist the urge to continue bumping the channel so that it never gets marked as inactive. If nobody is answering your question, you should try to reformulate the question to increase your chances of getting help. +The channel is yours until it has been inactive for **10 minutes**, or 30 minutes until someone participate in the channel. When this happens, we move the channel down to the **Python Help: Dormant** category, and make the channel read-only. After a while, the channel will be rotated back into **Python Help: Available** for the next question. Please try to resist the urge to continue bumping the channel so that it never gets marked as inactive. If nobody is answering your question, you should try to reformulate the question to increase your chances of getting help. ![Channel dormant message](/static/images/content/help_channels/dormant_message.png) *You'll see this message in your channel when the channel is marked as inactive.* -- cgit v1.2.3 From 2f5effe075287dab4965f3278031bcd433a83f7c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 26 Apr 2021 17:17:06 +0200 Subject: Filter: new schema This commit adds new filter schema as described in #479 --- pydis_site/apps/api/models/__init__.py | 5 + pydis_site/apps/api/models/bot/__init__.py | 2 +- pydis_site/apps/api/models/bot/filter_list.py | 42 ------ pydis_site/apps/api/models/bot/filters.py | 187 ++++++++++++++++++++++++ pydis_site/apps/api/viewsets/bot/filter_list.py | 2 +- 5 files changed, 194 insertions(+), 44 deletions(-) delete mode 100644 pydis_site/apps/api/models/bot/filter_list.py create mode 100644 pydis_site/apps/api/models/bot/filters.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index fd5bf220..72f59b57 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -1,6 +1,11 @@ # flake8: noqa from .bot import ( FilterList, + FilterSettings, + FilterAction, + ChannelRange, + Filter, + FilterOverride, BotSetting, DocumentationLink, DeletedMessage, diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index ac864de3..1bfe0063 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -1,5 +1,5 @@ # flake8: noqa -from .filter_list import FilterList +from .filters import FilterList, FilterSettings, FilterAction, ChannelRange, Filter, FilterOverride from .bot_setting import BotSetting from .deleted_message import DeletedMessage from .documentation_link import DocumentationLink diff --git a/pydis_site/apps/api/models/bot/filter_list.py b/pydis_site/apps/api/models/bot/filter_list.py deleted file mode 100644 index d30f7213..00000000 --- a/pydis_site/apps/api/models/bot/filter_list.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.db import models - -from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin - - -class FilterList(ModelTimestampMixin, ModelReprMixin, models.Model): - """An item that is either allowed or denied.""" - - FilterListType = models.TextChoices( - 'FilterListType', - 'GUILD_INVITE ' - 'FILE_FORMAT ' - 'DOMAIN_NAME ' - 'FILTER_TOKEN ' - 'REDIRECT ' - ) - type = models.CharField( - max_length=50, - help_text="The type of allowlist this is on.", - choices=FilterListType.choices, - ) - allowed = models.BooleanField( - help_text="Whether this item is on the allowlist or the denylist." - ) - content = models.TextField( - help_text="The data to add to the allow or denylist." - ) - comment = models.TextField( - help_text="Optional comment on this entry.", - null=True - ) - - class Meta: - """Metaconfig for this model.""" - - # This constraint ensures only one filterlist with the - # same content can exist. This means that we cannot have both an allow - # and a deny for the same item, and we cannot have duplicates of the - # same item. - constraints = [ - models.UniqueConstraint(fields=['content', 'type'], name='unique_filter_list'), - ] diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py new file mode 100644 index 00000000..dfc38e82 --- /dev/null +++ b/pydis_site/apps/api/models/bot/filters.py @@ -0,0 +1,187 @@ +from typing import List + +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import UniqueConstraint + + +class FilterListType(models.IntegerChoices): + """Choice between allow or deny for a list type.""" + + ALLOW: 1 + DENY: 0 + + +class InfractionType(models.TextChoices): + """Possible type of infractions.""" + + NOTE = "Note" + WARN = "Warn" + MUTE = "Mute" + KICK = "Kick" + BAN = "Ban" + + +# Valid special values in ping related fields +VALID_PINGS = ("everyone", "here", "moderators", "onduty", "admins") + + +def validate_ping_field(value_list: List[str]) -> None: + """Validate that the values are either a special value or a UID.""" + for value in value_list: + # Check if it is a special value + if value in VALID_PINGS: + continue + # Check if it is a UID + if value.isnumeric(): + continue + + raise ValidationError(f"{value!r} isn't a valid ping type.") + + +class FilterList(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="Whenever this list is an allowlist or denylist" + ) + + filters = models.ManyToManyField("Filter", help_text="The content of this list.") + default_settings = models.ForeignKey( + "FilterSettings", + models.CASCADE, + help_text="Default parameters of this list." + ) + + 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 {'allow' if self.list_type == 1 else 'deny'}list {self.name!r}" + + +class FilterSettings(models.Model): + """Persistent settings of a filter list.""" + + ping_type = ArrayField( + models.CharField(max_length=20), + validators=(validate_ping_field,), + help_text="Who to ping when this filter triggers." + ) + filter_dm = models.BooleanField(help_text="Whenever DMs should be filtered.") + dm_ping_type = ArrayField( + models.CharField(max_length=20), + validators=(validate_ping_field,), + help_text="Who to ping when this filter triggers on a DM." + ) + delete_messages = models.BooleanField( + help_text="Whenever this filter should delete messages triggering it." + ) + bypass_roles = ArrayField( + models.BigIntegerField(), + help_text="Roles and users who can bypass this filter." + ) + enabled = models.BooleanField( + help_text="Whenever ths filter is currently enabled." + ) + default_action = models.ForeignKey( + "FilterAction", + models.CASCADE, + help_text="The default action to perform." + ) + default_range = models.ForeignKey( + "ChannelRange", + models.CASCADE, + help_text="Where does this filter apply." + ) + + +class FilterAction(models.Model): + """The action to take when a filter is triggered.""" + + user_dm = models.CharField( + max_length=1000, + null=True, + help_text="The DM to send to a user triggering this filter." + ) + infraction_type = models.CharField( + choices=InfractionType.choices, + max_length=4, + null=True, + help_text="The infraction to apply to this user." + ) + infraction_reason = models.CharField( + max_length=1000, + help_text="The reason to give for the infraction." + ) + infraction_duration = models.DurationField( + null=True, + help_text="The duration of the infraction. Null if permanent." + ) + + +class ChannelRange(models.Model): + """ + Where a filter should apply. + + The resolution is done in the following order: + - disallowed channels + - disallowed categories + - allowed categories + - allowed channels + - default + """ + + disallowed_channels = ArrayField(models.IntegerField()) + disallowed_categories = ArrayField(models.IntegerField()) + allowed_channels = ArrayField(models.IntegerField()) + allowed_category = ArrayField(models.IntegerField()) + default = models.BooleanField() + + +class Filter(models.Model): + """One specific trigger of a list.""" + + content = models.CharField(max_length=100, help_text="The definition of this filter.") + description = models.CharField(max_length=200, help_text="Why this filter has been added.") + additional_field = models.BooleanField(null=True, help_text="Implementation specific field.") + override = models.ForeignKey( + "FilterOverride", + models.SET_NULL, + null=True, + help_text="Override the default settings." + ) + + def __str__(self) -> str: + return f"Filter {self.content!r}" + + +class FilterOverride(models.Model): + """ + Setting overrides of a specific filter. + + Any non-null value will override the default ones. + """ + + ping_type = ArrayField( + models.CharField(max_length=20), + validators=(validate_ping_field,), null=True + ) + filter_dm = models.BooleanField(null=True) + dm_ping_type = ArrayField( + models.CharField(max_length=20), + validators=(validate_ping_field,), + null=True + ) + delete_messages = models.BooleanField(null=True) + bypass_roles = ArrayField(models.IntegerField(), null=True) + enabled = models.BooleanField(null=True) + default_action = models.ForeignKey("FilterAction", models.CASCADE, null=True) + default_range = models.ForeignKey("ChannelRange", models.CASCADE, null=True) diff --git a/pydis_site/apps/api/viewsets/bot/filter_list.py b/pydis_site/apps/api/viewsets/bot/filter_list.py index 4b05acee..3eacdaaa 100644 --- a/pydis_site/apps/api/viewsets/bot/filter_list.py +++ b/pydis_site/apps/api/viewsets/bot/filter_list.py @@ -3,7 +3,7 @@ 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.models.bot.filters import FilterList from pydis_site.apps.api.serializers import FilterListSerializer -- cgit v1.2.3 From c6bcca08e58855cf3c3f87602f752dd40b10efad Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 26 Apr 2021 17:29:01 +0200 Subject: Filters: Add new models to Django Admin --- pydis_site/apps/api/admin.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) (limited to 'pydis_site/apps') 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 +@admin.register(FilterList) +class FilterListAdmin(admin.ModelAdmin): + """Admin formatting for the FilterList model.""" + + +@admin.register(Filter) +class FilterAdmin(admin.ModelAdmin): + """Admin formatting for the Filter model.""" + + @admin.register(MessageDeletionContext) class MessageDeletionContextAdmin(admin.ModelAdmin): """Admin formatting for the MessageDeletionContext model.""" -- cgit v1.2.3 From 87c78ceb49f6a2a0ab268fa2dde1850df5506eee Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 26 Apr 2021 17:30:10 +0200 Subject: Filters: Add migration to the new model This will take the currently defined filter list and put them inside the new schema while trying to keep defaults similar to our current setup. --- .../apps/api/migrations/0070_new_filter_schema.py | 165 +++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 pydis_site/apps/api/migrations/0070_new_filter_schema.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py new file mode 100644 index 00000000..e6d7ffe7 --- /dev/null +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -0,0 +1,165 @@ +# Modified migration file to migrate existing filters to the new one + +import django.contrib.postgres.fields +from django.apps.registry import Apps +from django.db import migrations, models +import django.db.models.deletion +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import pydis_site.apps.api.models.bot.filters + +OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY')) + + +def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter") + filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList") + filter_settings: pydis_site.apps.api.models.FilterSettings = apps.get_model("api", "FilterSettings") + channel_range: pydis_site.apps.api.models.ChannelRange = apps.get_model("api", "ChannelRange") + filter_action: pydis_site.apps.api.models.FilterAction = apps.get_model("api", "FilterAction") + filter_list_old = apps.get_model("api", "FilterListOld") + + for name, type_ in OLD_LIST_NAMES: + objects = filter_list_old.objects.filter(type=name) + + default_action = filter_action.objects.create( + user_dm=None, + infraction_type=None, + infraction_reason="", + infraction_duration=None + ) + default_action.save() + default_range = channel_range.objects.create( + disallowed_channels=[], + disallowed_categories=[], + allowed_channels=[], + allowed_category=[], + default=True + ) + default_range.save() + default_settings = filter_settings.objects.create( + ping_type=["onduty"], + filter_dm=True, + dm_ping_type=["onduty"], + delete_messages=True, + bypass_roles=[267630620367257601], + enabled=False, + default_action=default_action, + default_range=default_range + ) + default_settings.save() + list_ = filter_list.objects.create( + name=name.lower(), + default_settings=default_settings, + list_type=1 if type_ == "ALLOW" else 0 + ) + + new_objects = [] + for object_ in objects: + new_object = filter_.objects.create( + content=object_.content, + description=object_.comment or "", + additional_field=None, override=None + ) + new_object.save() + new_objects.append(new_object) + + list_.filters.add(*new_objects) + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0069_documentationlink_validators'), + ] + + operations = [ + migrations.RenameModel( + old_name='FilterList', + new_name='FilterListOld' + ), + migrations.CreateModel( + name='ChannelRange', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('allowed_category', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('default', models.BooleanField()), + ], + ), + migrations.CreateModel( + name='Filter', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), + ('description', models.CharField(help_text='Why this filter has been added.', max_length=200)), + ('additional_field', models.BooleanField(help_text='Implementation specific field.', null=True)), + ], + ), + migrations.CreateModel( + name='FilterAction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_dm', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), + ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)), + ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), + ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), + ], + ), + migrations.CreateModel( + name='FilterSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), + ('filter_dm', models.BooleanField(help_text='Whenever DMs should be filtered.')), + ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), + ('delete_messages', models.BooleanField(help_text='Whenever this filter should delete messages triggering it.')), + ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None)), + ('enabled', models.BooleanField(help_text='Whenever ths filter is currently enabled.')), + ('default_action', models.ForeignKey(help_text='The default action to perform.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')), + ('default_range', models.ForeignKey(help_text='Where does this filter apply.', on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')), + ], + ), + migrations.CreateModel( + name='FilterOverride', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), null=True, size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), + ('filter_dm', models.BooleanField(null=True)), + ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), null=True, size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), + ('delete_messages', models.BooleanField(null=True)), + ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), + ('enabled', models.BooleanField(null=True)), + ('default_action', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')), + ('default_range', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')), + ], + ), + migrations.CreateModel( + name='FilterList', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), + ('list_type', models.IntegerField(choices=[], help_text='Whenever this list is an allowlist or denylist')), + ('default_settings', models.ForeignKey(help_text='Default parameters of this list.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterSettings')), + ('filters', models.ManyToManyField(help_text='The content of this list.', to='api.Filter')), + ], + ), + migrations.AddField( + model_name='filter', + name='override', + field=models.ForeignKey(help_text='Override the default settings.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.FilterOverride'), + ), + 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' + ) + ] -- cgit v1.2.3 From 64c5c617df68cc7b54fffbe8d76bb5c67d641c98 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 27 Apr 2021 10:54:21 +0200 Subject: Filters: hook the new models into the REST API --- .../apps/api/migrations/0070_new_filter_schema.py | 4 +- pydis_site/apps/api/models/bot/filters.py | 4 +- pydis_site/apps/api/serializers.py | 98 +++- pydis_site/apps/api/urls.py | 29 +- pydis_site/apps/api/viewsets/__init__.py | 7 +- pydis_site/apps/api/viewsets/bot/__init__.py | 9 +- pydis_site/apps/api/viewsets/bot/filter_list.py | 98 ---- pydis_site/apps/api/viewsets/bot/filters.py | 640 +++++++++++++++++++++ 8 files changed, 773 insertions(+), 116 deletions(-) delete mode 100644 pydis_site/apps/api/viewsets/bot/filter_list.py create mode 100644 pydis_site/apps/api/viewsets/bot/filters.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index e6d7ffe7..f4fc9494 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -132,8 +132,8 @@ class Migration(migrations.Migration): ('delete_messages', models.BooleanField(null=True)), ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), ('enabled', models.BooleanField(null=True)), - ('default_action', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')), - ('default_range', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')), + ('filter_action', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')), + ('filter_range', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')), ], ), migrations.CreateModel( diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index dfc38e82..16ac206e 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -183,5 +183,5 @@ class FilterOverride(models.Model): delete_messages = models.BooleanField(null=True) bypass_roles = ArrayField(models.IntegerField(), null=True) enabled = models.BooleanField(null=True) - default_action = models.ForeignKey("FilterAction", models.CASCADE, null=True) - default_range = models.ForeignKey("ChannelRange", models.CASCADE, null=True) + filter_action = models.ForeignKey("FilterAction", models.CASCADE, null=True) + filter_range = models.ForeignKey("ChannelRange", models.CASCADE, null=True) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index de2fccff..306dccb3 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -12,12 +12,17 @@ from rest_framework.serializers import ( from rest_framework.settings import api_settings from rest_framework.validators import UniqueTogetherValidator -from .models import ( +from .models import ( # noqa: I101 - Preserving the filter order BotSetting, DeletedMessage, DocumentationLink, - FilterList, Infraction, + FilterList, + FilterSettings, + FilterAction, + ChannelRange, + Filter, + FilterOverride, MessageDeletionContext, Nomination, NominationEntry, @@ -119,24 +124,97 @@ class FilterListSerializer(ModelSerializer): """Metadata defined for the Django REST Framework.""" model = FilterList - fields = ('id', 'created_at', 'updated_at', 'type', 'allowed', 'content', 'comment') + fields = ('id', 'name', 'list_type', 'filters', 'default_settings') - # 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 that we can only have one filter list with the same name and field validators = [ UniqueTogetherValidator( queryset=FilterList.objects.all(), - fields=['content', 'type'], + fields=('name', 'list_type'), message=( - "A filterlist for this item already exists. " - "Please note that you cannot add the same item to both allow and deny." + "A filterlist with the same name and type already exist." ) ), ] +class FilterSettingsSerializer(ModelSerializer): + """A class providing (de-)serialization of `FilterSettings` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = FilterSettings + fields = ( + 'id', + 'ping_type', + 'filter_dm', + 'dm_ping_type', + 'delete_messages', + 'bypass_roles', + 'enabled', + 'default_action', + 'default_range' + ) + + +class FilterActionSerializer(ModelSerializer): + """A class providing (de-)serialization of `FilterAction` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = FilterAction + fields = ('id', 'user_dm', 'infraction_type', 'infraction_reason', 'infraction_duration') + + +class FilterChannelRangeSerializer(ModelSerializer): + """A class providing (de-)serialization of `ChannelRange` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = ChannelRange + fields = ( + 'id', + 'disallowed_channels', + 'disallowed_categories', + 'allowed_channels', + 'allowed_category', + 'default' + ) + + +class FilterSerializer(ModelSerializer): + """A class providing (de-)serialization of `Filter` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = Filter + fields = ('id', 'content', 'description', 'additional_field', 'override') + + +class FilterOverrideSerializer(ModelSerializer): + """A class providing (de-)serialization of `FilterOverride` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = FilterOverride + fields = ( + 'id', + 'ping_type', + 'filter_dm', + 'dm_ping_type', + 'delete_messages', + 'bypass_roles', + 'enabled', + 'filter_action', + 'filter_range' + ) + + class InfractionSerializer(ModelSerializer): """A class providing (de-)serialization of `Infraction` instances.""" diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index b0ab545b..7af2e505 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -2,11 +2,16 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter from .views import HealthcheckView, RulesView -from .viewsets import ( +from .viewsets import ( # noqa: I101 - Preserving the filter order BotSettingViewSet, DeletedMessageViewSet, DocumentationLinkViewSet, FilterListViewSet, + FilterSettingsViewSet, + FilterActionViewSet, + FilterChannelRangeViewSet, + FilterViewSet, + FilterOverrideViewSet, InfractionViewSet, NominationViewSet, OffTopicChannelNameViewSet, @@ -19,9 +24,29 @@ from .viewsets import ( # https://www.django-rest-framework.org/api-guide/routers/#defaultrouter bot_router = DefaultRouter(trailing_slash=False) bot_router.register( - 'filter-lists', + 'filter/filter_lists', FilterListViewSet ) +bot_router.register( + 'filter/filter_settings', + FilterSettingsViewSet +) +bot_router.register( + 'filter/filter_action', + FilterActionViewSet +) +bot_router.register( + 'filter/channel_range', + FilterChannelRangeViewSet +) +bot_router.register( + 'filter/filter_override', + FilterOverrideViewSet +) +bot_router.register( + 'filter/filters', + FilterViewSet +) bot_router.register( 'bot-settings', BotSettingViewSet diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index f133e77f..b3992d66 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -1,10 +1,15 @@ # flake8: noqa from .bot import ( - FilterListViewSet, BotSettingViewSet, DeletedMessageViewSet, DocumentationLinkViewSet, InfractionViewSet, + FilterListViewSet, + FilterSettingsViewSet, + FilterActionViewSet, + FilterChannelRangeViewSet, + FilterViewSet, + FilterOverrideViewSet, NominationViewSet, OffensiveMessageViewSet, OffTopicChannelNameViewSet, diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py index 84b87eab..781624bd 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -1,5 +1,12 @@ # flake8: noqa -from .filter_list import FilterListViewSet +from .filters import ( + FilterListViewSet, + FilterSettingsViewSet, + FilterActionViewSet, + FilterChannelRangeViewSet, + FilterViewSet, + FilterOverrideViewSet +) from .bot_setting import BotSettingViewSet from .deleted_message import DeletedMessageViewSet from .documentation_link import DocumentationLinkViewSet diff --git a/pydis_site/apps/api/viewsets/bot/filter_list.py b/pydis_site/apps/api/viewsets/bot/filter_list.py deleted file mode 100644 index 3eacdaaa..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.filters 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/ - 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/ - 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..fea53265 --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -0,0 +1,640 @@ +from rest_framework.viewsets import ModelViewSet + +from pydis_site.apps.api.models.bot.filters import ( # noqa: I101 - Preserving the filter order + FilterList, + FilterSettings, + FilterAction, + ChannelRange, + Filter, + FilterOverride +) +from pydis_site.apps.api.serializers import ( # noqa: I101 - Preserving the filter order + FilterListSerializer, + FilterSettingsSerializer, + FilterActionSerializer, + FilterChannelRangeSerializer, + FilterSerializer, + FilterOverrideSerializer +) + + +class FilterListViewSet(ModelViewSet): + """ + View providing CRUD operations on lists of items allowed or denied by our bot. + + ## Routes + ### GET /bot/filter/filter_lists + Returns all FilterList items in the database. + + #### Response format + >>> [ + ... { + ... "id": 1, + ... "name": "guild_invite", + ... "list_type": 1, + ... "filters": [ + ... 1, + ... 2, + ... ... + ... ], + ... "default_settings": 1 + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + - 401: returned if unauthenticated + + ### GET /bot/filter/filter_lists/ + Returns a specific FilterList item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "name": "guild_invite", + ... "list_type": 1, + ... "filters": [ + ... 1, + ... 2, + ... ... + ... ], + ... "default_settings": 1 + ... } + + #### 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": "guild_invite", + ... "list_type": 1, + ... "filters": [ + ... 1, + ... 2, + ... ... + ... ], + ... "default_settings": 1 + ... } + + #### Status codes + - 201: returned on success + - 400: if one of the given fields is invalid + + ### PATCH /bot/filter/filter_lists/ + Updates a specific FilterList item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "name": "guild_invite", + ... "list_type": 1, + ... "filters": [ + ... 1, + ... 2, + ... ... + ... ], + ... "default_settings": 1 + ... } + + #### Status codes + - 200: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/filter/filter_lists/ + Deletes the FilterList item with the given `id`. + + #### Status codes + - 204: returned on success + - 404: if a tag with the given `id` does not exist + """ + + serializer_class = FilterListSerializer + queryset = FilterList.objects.all() + + +class FilterSettingsViewSet(ModelViewSet): + """ + View providing CRUD operations on settings of items allowed or denied by our bot. + + ## Routes + ### GET /bot/filter/filter_settings + Returns all FilterSettings items in the database. + + #### Response format + >>> [ + ... { + ... "id": 1, + ... "ping_type": [ + ... "onduty", + ... ... + ... ], + ... "filter_dm": True, + ... "dm_ping_type": [ + ... "onduty", + ... ... + ... ], + ... "delete_messages": True, + ... "bypass_roles": [ + ... 267630620367257601, + ... ... + ... ], + ... "enabled": True, + ... "default_action": 1, + ... "default_range": 1 + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + - 401: returned if unauthenticated + + ### GET /bot/filter/filter_settings/ + Returns a specific FilterSettings item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "ping_type": [ + ... "onduty", + ... ... + ... ], + ... "filter_dm": True, + ... "dm_ping_type": [ + ... "onduty", + ... ... + ... ], + ... "delete_messages": True, + ... "bypass_roles": [ + ... 267630620367257601, + ... ... + ... ], + ... "enabled": True, + ... "default_action": 1, + ... "default_range": 1 + ... } + + #### Status codes + - 200: returned on success + - 404: returned if the id was not found. + + ### POST /bot/filter/filter_settings + Adds a single FilterSettings item to the database. + + #### Request body + >>> { + ... "ping_type": [ + ... "onduty", + ... ... + ... ], + ... "filter_dm": True, + ... "dm_ping_type": [ + ... "onduty", + ... ... + ... ], + ... "delete_messages": True, + ... "bypass_roles": [ + ... 267630620367257601, + ... ... + ... ], + ... "enabled": True, + ... "default_action": 1, + ... "default_range": 1 + ... } + + #### Status codes + - 201: returned on success + - 400: if one of the given fields is invalid + + ### PATCH /bot/filter/filter_settings/ + Updates a specific FilterSettings item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "ping_type": [ + ... "onduty", + ... ... + ... ], + ... "filter_dm": True, + ... "dm_ping_type": [ + ... "onduty", + ... ... + ... ], + ... "delete_messages": True, + ... "bypass_roles": [ + ... 267630620367257601, + ... ... + ... ], + ... "enabled": True, + ... "default_action": 1, + ... "default_range": 1 + ... } + + #### Status codes + - 200: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/filter/filter_settings/ + Deletes the FilterSettings item with the given `id`. + + #### Status codes + - 204: returned on success + - 404: if a tag with the given `id` does not exist + """ + + serializer_class = FilterSettingsSerializer + queryset = FilterSettings.objects.all() + + +class FilterActionViewSet(ModelViewSet): + """ + View providing CRUD operations on actions taken by items allowed or denied by our bot. + + ## Routes + ### GET /bot/filter/filter_action + Returns all FilterAction items in the database. + + #### Response format + >>> [ + ... { + ... "id": 1, + ... "user_dm": "message", + ... "infraction_type": "Warn", + ... "infraction_reason": "", + ... "infraction_duration": "01 12:34:56.123456" + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + - 401: returned if unauthenticated + + ### GET /bot/filter/filter_action/ + Returns a specific FilterAction item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "user_dm": "message", + ... "infraction_type": "Warn", + ... "infraction_reason": "", + ... "infraction_duration": "01 12:34:56.123456" + ... } + + #### Status codes + - 200: returned on success + - 404: returned if the id was not found. + + ### POST /bot/filter/filter_action + Adds a single FilterAction item to the database. + + #### Request body + >>> { + ... "user_dm": "message", + ... "infraction_type": "Warn", + ... "infraction_reason": "", + ... "infraction_duration": "01 12:34:56.123456" + ... } + + #### Status codes + - 201: returned on success + - 400: if one of the given fields is invalid + + ### PATCH /bot/filter/filter_action/ + Updates a specific FilterAction item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "user_dm": "message", + ... "infraction_type": "Warn", + ... "infraction_reason": "", + ... "infraction_duration": "01 12:34:56.123456" + ... } + + #### Status codes + - 200: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/filter/filter_action/ + Deletes the FilterAction item with the given `id`. + + #### Status codes + - 204: returned on success + - 404: if a tag with the given `id` does not exist + """ + + serializer_class = FilterActionSerializer + queryset = FilterAction.objects.all() + + +class FilterChannelRangeViewSet(ModelViewSet): + """ + View providing CRUD operations on channels targeted by items allowed or denied by our bot. + + ## Routes + ### GET /bot/filter/channel_range + Returns all ChannelRange items in the database. + + #### Response format + >>> [ + ... { + ... "id": 1, + ... "disallowed_channels": [], + ... "disallowed_categories": [], + ... "allowed_channels": [], + ... "allowed_category": [], + ... "default": True + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + - 401: returned if unauthenticated + + ### GET /bot/filter/channel_range/ + Returns a specific ChannelRange item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "disallowed_channels": [], + ... "disallowed_categories": [], + ... "allowed_channels": [], + ... "allowed_category": [], + ... "default": True + ... } + + #### Status codes + - 200: returned on success + - 404: returned if the id was not found. + + ### POST /bot/filter/channel_range + Adds a single ChannelRange item to the database. + + #### Request body + >>> { + ... "disallowed_channels": [], + ... "disallowed_categories": [], + ... "allowed_channels": [], + ... "allowed_category": [], + ... "default": True + ... } + + #### Status codes + - 201: returned on success + - 400: if one of the given fields is invalid + + ### PATCH /bot/filter/channel_range/ + Updates a specific ChannelRange item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "disallowed_channels": [], + ... "disallowed_categories": [], + ... "allowed_channels": [], + ... "allowed_category": [], + ... "default": True + ... } + + #### Status codes + - 200: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/filter/channel_range/ + Deletes the ChannelRange item with the given `id`. + + #### Status codes + - 204: returned on success + - 404: if a tag with the given `id` does not exist + """ + + serializer_class = FilterChannelRangeSerializer + queryset = ChannelRange.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, + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_field": None, + ... "override": 1 + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + - 401: returned if unauthenticated + + ### GET /bot/filter/filters/ + Returns a specific Filter item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_field": None, + ... "override": 1 + ... } + + #### Status codes + - 200: returned on success + - 404: returned if the id was not found. + + ### POST /bot/filter/filters + Adds a single Filter item to the database. + + #### Request body + >>> { + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_field": None, + ... "override": 1 + ... } + + #### Status codes + - 201: returned on success + - 400: if one of the given fields is invalid + + ### PATCH /bot/filter/filters/ + Updates a specific Filter item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_field": None, + ... "override": 1 + ... } + + #### Status codes + - 200: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/filter/filters/ + Deletes the Filter item with the given `id`. + + #### Status codes + - 204: returned on success + - 404: if a tag with the given `id` does not exist + """ + + serializer_class = FilterSerializer + queryset = Filter.objects.all() + + +class FilterOverrideViewSet(ModelViewSet): + """ + View providing CRUD operations setting overrides of items allowed or denied by our bot. + + ## Routes + ### GET /bot/filter/filter_override + Returns all FilterOverride items in the database. + + #### Response format + >>> [ + ... { + ... "id": 1, + ... "ping_type": [ + ... "onduty", + ... ... + ... ], + ... "filter_dm": True, + ... "dm_ping_type": [ + ... "onduty", + ... ... + ... ], + ... "delete_messages": True, + ... "bypass_roles": [ + ... 267630620367257601, + ... ... + ... ], + ... "enabled": True, + ... "filter_action": 1, + ... "filter_range": 1 + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + - 401: returned if unauthenticated + + ### GET /bot/filter/filter_override/ + Returns a specific FilterOverride item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "ping_type": [ + ... "onduty", + ... ... + ... ], + ... "filter_dm": True, + ... "dm_ping_type": [ + ... "onduty", + ... ... + ... ], + ... "delete_messages": True, + ... "bypass_roles": [ + ... 267630620367257601, + ... ... + ... ], + ... "enabled": True, + ... "filter_action": 1, + ... "filter_range": 1 + ... } + + #### Status codes + - 200: returned on success + - 404: returned if the id was not found. + + ### POST /bot/filter/filter_override + Adds a single FilterOverride item to the database. + + #### Request body + >>> { + ... "ping_type": [ + ... "onduty", + ... ... + ... ], + ... "filter_dm": True, + ... "dm_ping_type": [ + ... "onduty", + ... ... + ... ], + ... "delete_messages": True, + ... "bypass_roles": [ + ... 267630620367257601, + ... ... + ... ], + ... "enabled": True, + ... "filter_action": 1, + ... "filter_range": 1 + ... } + + #### Status codes + - 201: returned on success + - 400: if one of the given fields is invalid + + ### PATCH /bot/filter/filter_override/ + Updates a specific FilterOverride item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "ping_type": [ + ... "onduty", + ... ... + ... ], + ... "filter_dm": True, + ... "dm_ping_type": [ + ... "onduty", + ... ... + ... ], + ... "delete_messages": True, + ... "bypass_roles": [ + ... 267630620367257601, + ... ... + ... ], + ... "enabled": True, + ... "filter_action": 1, + ... "filter_range": 1 + ... } + + #### Status codes + - 200: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/filter/filter_override/ + Deletes the FilterOverride item with the given `id`. + + #### Status codes + - 204: returned on success + - 404: if a tag with the given `id` does not exist + """ + + serializer_class = FilterOverrideSerializer + queryset = FilterOverride.objects.all() -- cgit v1.2.3 From c6dfd896304cb4e36c4020f4704d9537fd3e8e9f Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 27 Apr 2021 16:39:00 +0200 Subject: Filters: update tests to the new schema --- .../apps/api/migrations/0070_new_filter_schema.py | 2 +- pydis_site/apps/api/models/bot/filters.py | 2 +- pydis_site/apps/api/tests/test_filterlists.py | 122 --------- pydis_site/apps/api/tests/test_filters.py | 284 +++++++++++++++++++++ pydis_site/apps/api/tests/test_models.py | 14 + 5 files changed, 300 insertions(+), 124 deletions(-) delete mode 100644 pydis_site/apps/api/tests/test_filterlists.py create mode 100644 pydis_site/apps/api/tests/test_filters.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index f4fc9494..de75e677 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -143,7 +143,7 @@ class Migration(migrations.Migration): ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), ('list_type', models.IntegerField(choices=[], help_text='Whenever this list is an allowlist or denylist')), ('default_settings', models.ForeignKey(help_text='Default parameters of this list.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterSettings')), - ('filters', models.ManyToManyField(help_text='The content of this list.', to='api.Filter')), + ('filters', models.ManyToManyField(help_text='The content of this list.', to='api.Filter', default=[])), ], ), migrations.AddField( diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 16ac206e..869f947c 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -49,7 +49,7 @@ class FilterList(models.Model): help_text="Whenever this list is an allowlist or denylist" ) - filters = models.ManyToManyField("Filter", help_text="The content of this list.") + filters = models.ManyToManyField("Filter", help_text="The content of this list.", default=[]) default_settings = models.ForeignKey( "FilterSettings", models.CASCADE, diff --git a/pydis_site/apps/api/tests/test_filterlists.py b/pydis_site/apps/api/tests/test_filterlists.py deleted file mode 100644 index 5a5bca60..00000000 --- a/pydis_site/apps/api/tests/test_filterlists.py +++ /dev/null @@ -1,122 +0,0 @@ -from django.urls import reverse - -from pydis_site.apps.api.models import FilterList -from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase - -URL = reverse('api:bot:filterlist-list') -JPEG_ALLOWLIST = { - "type": 'FILE_FORMAT', - "allowed": True, - "content": ".jpeg", -} -PNG_ALLOWLIST = { - "type": 'FILE_FORMAT', - "allowed": True, - "content": ".png", -} - - -class UnauthenticatedTests(AuthenticatedAPITestCase): - def setUp(self): - super().setUp() - self.client.force_authenticate(user=None) - - def test_cannot_read_allowedlist_list(self): - response = self.client.get(URL) - - self.assertEqual(response.status_code, 401) - - -class EmptyDatabaseTests(AuthenticatedAPITestCase): - @classmethod - def setUpTestData(cls): - FilterList.objects.all().delete() - - def test_returns_empty_object(self): - response = self.client.get(URL) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), []) - - -class FetchTests(AuthenticatedAPITestCase): - @classmethod - def setUpTestData(cls): - FilterList.objects.all().delete() - cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST) - cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST) - - def test_returns_name_in_list(self): - response = self.client.get(URL) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()[0]["content"], self.jpeg_format.content) - self.assertEqual(response.json()[1]["content"], self.png_format.content) - - def test_returns_single_item_by_id(self): - response = self.client.get(f'{URL}/{self.jpeg_format.id}') - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json().get("content"), self.jpeg_format.content) - - def test_returns_filter_list_types(self): - response = self.client.get(f'{URL}/get-types') - - self.assertEqual(response.status_code, 200) - for api_type, model_type in zip(response.json(), FilterList.FilterListType.choices): - self.assertEquals(api_type[0], model_type[0]) - self.assertEquals(api_type[1], model_type[1]) - - -class CreationTests(AuthenticatedAPITestCase): - @classmethod - def setUpTestData(cls): - FilterList.objects.all().delete() - - def test_returns_400_for_missing_params(self): - no_type_json = { - "allowed": True, - "content": ".jpeg" - } - no_allowed_json = { - "type": "FILE_FORMAT", - "content": ".jpeg" - } - no_content_json = { - "allowed": True, - "type": "FILE_FORMAT" - } - cases = [{}, no_type_json, no_allowed_json, no_content_json] - - for case in cases: - with self.subTest(case=case): - response = self.client.post(URL, data=case) - self.assertEqual(response.status_code, 400) - - def test_returns_201_for_successful_creation(self): - response = self.client.post(URL, data=JPEG_ALLOWLIST) - self.assertEqual(response.status_code, 201) - - def test_returns_400_for_duplicate_creation(self): - self.client.post(URL, data=JPEG_ALLOWLIST) - response = self.client.post(URL, data=JPEG_ALLOWLIST) - self.assertEqual(response.status_code, 400) - - -class DeletionTests(AuthenticatedAPITestCase): - @classmethod - def setUpTestData(cls): - FilterList.objects.all().delete() - cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST) - cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST) - - def test_deleting_unknown_id_returns_404(self): - response = self.client.delete(f"{URL}/200") - self.assertEqual(response.status_code, 404) - - def test_deleting_known_id_returns_204(self): - response = self.client.delete(f"{URL}/{self.jpeg_format.id}") - self.assertEqual(response.status_code, 204) - - response = self.client.get(f"{URL}/{self.jpeg_format.id}") - self.assertNotIn(self.png_format.content, response.json()) diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py new file mode 100644 index 00000000..de78ecfd --- /dev/null +++ b/pydis_site/apps/api/tests/test_filters.py @@ -0,0 +1,284 @@ +import contextlib +from dataclasses import dataclass +from typing import Any, Dict, Tuple, Type + +from django.db.models import Model +from django_hosts import reverse + +from pydis_site.apps.api.models.bot.filters import ( # noqa: I101 - Preserving the filter order + FilterList, + FilterSettings, + FilterAction, + ChannelRange, + Filter, + FilterOverride +) +from pydis_site.apps.api.tests.base import APISubdomainTestCase + + +@dataclass() +class TestSequence: + model: Type[Model] + route: str + object: Dict[str, Any] + ignored_fields: Tuple[str] = () + + def url(self, detail: bool = False) -> str: + return reverse(f'bot:{self.route}-{"detail" if detail else "list"}', host='api') + + +FK_FIELDS: Dict[Type[Model], Tuple[str]] = { + FilterList: ("default_settings",), + FilterSettings: ("default_action", "default_range"), + FilterAction: (), + ChannelRange: (), + Filter: (), + FilterOverride: ("filter_action", "filter_range") +} + + +def get_test_sequences() -> Dict[str, TestSequence]: + return { + "filter_list": TestSequence( + FilterList, + "filterlist", + { + "name": "testname", + "list_type": 0, + "default_settings": FilterSettings( + ping_type=[], + filter_dm=False, + dm_ping_type=[], + delete_messages=False, + bypass_roles=[], + enabled=False, + default_action=FilterAction( + user_dm=None, + infraction_type=None, + infraction_reason="", + infraction_duration=None + ), + default_range=ChannelRange( + disallowed_channels=[], + disallowed_categories=[], + allowed_channels=[], + allowed_category=[], + default=False + ) + ) + }, + ignored_fields=("filters",) + ), + "filter_settings": TestSequence( + FilterSettings, + "filtersettings", + { + "ping_type": ["onduty"], + "filter_dm": True, + "dm_ping_type": ["123456"], + "delete_messages": True, + "bypass_roles": [123456], + "enabled": True, + "default_action": FilterAction( + user_dm=None, + infraction_type=None, + infraction_reason="", + infraction_duration=None + ), + "default_range": ChannelRange( + disallowed_channels=[], + disallowed_categories=[], + allowed_channels=[], + allowed_category=[], + default=False + ) + } + ), + "filter_action": TestSequence( + FilterAction, + "filteraction", + { + "user_dm": "This is a DM message.", + "infraction_type": "Mute", + "infraction_reason": "Too long beard", + "infraction_duration": "1 02:03:00" + } + ), + "channel_range": TestSequence( + ChannelRange, + "channelrange", + { + "disallowed_channels": [1234], + "disallowed_categories": [5678], + "allowed_channels": [9101], + "allowed_category": [1121], + "default": True + } + ), + "filter": TestSequence( + Filter, + "filter", + { + "content": "bad word", + "description": "This is a really bad word.", + "additional_field": None, + "override": None + } + ), + "filter_override": TestSequence( + FilterOverride, + "filteroverride", + { + "ping_type": ["everyone"], + "filter_dm": False, + "dm_ping_type": ["here"], + "delete_messages": False, + "bypass_roles": [9876], + "enabled": True, + "filter_action": None, + "filter_range": None + } + ) + } + + +def save_nested_objects(object_: Model, save_root: bool = True) -> None: + for field in FK_FIELDS[object_.__class__]: + value = getattr(object_, field) + + if value is not None: + save_nested_objects(value) + + if save_root: + object_.save() + + +def clean_test_json(json: dict) -> dict: + for key, value in json.items(): + if isinstance(value, Model): + json[key] = value.id + + return json + + +def clean_api_json(json: dict, sequence: TestSequence) -> dict: + for field in sequence.ignored_fields + ("id",): + with contextlib.suppress(KeyError): + del json[field] + + return json + + +class GenericFilterTest(APISubdomainTestCase): + def test_cannot_read_unauthenticated(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + self.client.force_authenticate(user=None) + + response = self.client.get(sequence.url()) + self.assertEqual(response.status_code, 401) + + def test_empty_database(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + response = self.client.get(sequence.url()) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_fetch(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + save_nested_objects(sequence.model(**sequence.object)) + + response = self.client.get(sequence.url()) + self.assertDictEqual( + clean_test_json(sequence.object), + clean_api_json(response.json()[0], sequence) + ) + + def test_fetch_by_id(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + saved = sequence.model(**sequence.object) + save_nested_objects(saved) + + response = self.client.get(f"{sequence.url()}/{saved.id}") + self.assertDictEqual( + clean_test_json(sequence.object), + clean_api_json(response.json(), sequence) + ) + + def test_fetch_non_existing(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + response = self.client.get(f"{sequence.url()}/42") + self.assertEqual(response.status_code, 404) + self.assertDictEqual(response.json(), {'detail': 'Not found.'}) + + def test_creation(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + save_nested_objects(sequence.model(**sequence.object), False) + data = clean_test_json(sequence.object.copy()) + response = self.client.post(sequence.url(), data=data) + + self.assertEqual(response.status_code, 201) + self.assertDictEqual( + clean_api_json(response.json(), sequence), + clean_test_json(sequence.object) + ) + + def test_creation_missing_field(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + save_nested_objects(sequence.model(**sequence.object), False) + data = clean_test_json(sequence.object.copy()) + + for field in sequence.model._meta.get_fields(): + with self.subTest(field=field): + if field.null or field.name in sequence.ignored_fields + ("id",): + continue + + test_data = data.copy() + del test_data[field.name] + + response = self.client.post(sequence.url(), data=test_data) + self.assertEqual(response.status_code, 400) + + def test_deletion(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + saved = sequence.model(**sequence.object) + save_nested_objects(saved) + + response = self.client.delete(f"{sequence.url()}/{saved.id}") + self.assertEqual(response.status_code, 204) + + def test_deletion_non_existing(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + response = self.client.delete(f"{sequence.url()}/42") + self.assertEqual(response.status_code, 404) + + def test_reject_invalid_ping(self) -> None: + url = reverse('bot:filteroverride-list', host='api') + data = { + "ping_type": ["invalid"] + } + + response = self.client.post(url, data=data) + + self.assertEqual(response.status_code, 400) + self.assertDictEqual(response.json(), {'ping_type': ["'invalid' isn't a valid ping type."]}) diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index 5c9ddea4..c8f4e1b1 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -7,6 +7,9 @@ from django.utils import timezone from pydis_site.apps.api.models import ( DeletedMessage, DocumentationLink, + Filter, + FilterList, + FilterSettings, Infraction, Message, MessageDeletionContext, @@ -106,6 +109,17 @@ class StringDunderMethodTests(SimpleTestCase): DocumentationLink( 'test', 'http://example.com', 'http://example.com' ), + FilterList( + name="forbidden_duckies", + list_type=0, + default_settings=FilterSettings() + ), + Filter( + content="ducky_nsfw", + description="This ducky is totally inappropriate!", + additional_field=None, + override=None + ), OffensiveMessage( id=602951077675139072, channel_id=291284109232308226, -- cgit v1.2.3 From 71a5e0d854c587ca2ae70aaec80f1110ea8800e5 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 27 Apr 2021 16:41:18 +0200 Subject: Filters: allowed_category -> allowed_categories --- pydis_site/apps/api/migrations/0070_new_filter_schema.py | 4 ++-- pydis_site/apps/api/models/bot/filters.py | 2 +- pydis_site/apps/api/serializers.py | 2 +- pydis_site/apps/api/tests/test_filters.py | 6 +++--- pydis_site/apps/api/viewsets/bot/filters.py | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index de75e677..eb55e329 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -33,7 +33,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: disallowed_channels=[], disallowed_categories=[], allowed_channels=[], - allowed_category=[], + allowed_categories=[], default=True ) default_range.save() @@ -85,7 +85,7 @@ class Migration(migrations.Migration): ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('allowed_category', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), ('default', models.BooleanField()), ], ), diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 869f947c..c2f776d3 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -142,7 +142,7 @@ class ChannelRange(models.Model): disallowed_channels = ArrayField(models.IntegerField()) disallowed_categories = ArrayField(models.IntegerField()) allowed_channels = ArrayField(models.IntegerField()) - allowed_category = ArrayField(models.IntegerField()) + allowed_categories = ArrayField(models.IntegerField()) default = models.BooleanField() diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 306dccb3..54acf366 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -180,7 +180,7 @@ class FilterChannelRangeSerializer(ModelSerializer): 'disallowed_channels', 'disallowed_categories', 'allowed_channels', - 'allowed_category', + 'allowed_categories', 'default' ) diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py index de78ecfd..f38f3659 100644 --- a/pydis_site/apps/api/tests/test_filters.py +++ b/pydis_site/apps/api/tests/test_filters.py @@ -62,7 +62,7 @@ def get_test_sequences() -> Dict[str, TestSequence]: disallowed_channels=[], disallowed_categories=[], allowed_channels=[], - allowed_category=[], + allowed_categories=[], default=False ) ) @@ -89,7 +89,7 @@ def get_test_sequences() -> Dict[str, TestSequence]: disallowed_channels=[], disallowed_categories=[], allowed_channels=[], - allowed_category=[], + allowed_categories=[], default=False ) } @@ -111,7 +111,7 @@ def get_test_sequences() -> Dict[str, TestSequence]: "disallowed_channels": [1234], "disallowed_categories": [5678], "allowed_channels": [9101], - "allowed_category": [1121], + "allowed_categories": [1121], "default": True } ), diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index fea53265..e290fc65 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -350,7 +350,7 @@ class FilterChannelRangeViewSet(ModelViewSet): ... "disallowed_channels": [], ... "disallowed_categories": [], ... "allowed_channels": [], - ... "allowed_category": [], + ... "allowed_categories": [], ... "default": True ... }, ... ... @@ -369,7 +369,7 @@ class FilterChannelRangeViewSet(ModelViewSet): ... "disallowed_channels": [], ... "disallowed_categories": [], ... "allowed_channels": [], - ... "allowed_category": [], + ... "allowed_categories": [], ... "default": True ... } @@ -385,7 +385,7 @@ class FilterChannelRangeViewSet(ModelViewSet): ... "disallowed_channels": [], ... "disallowed_categories": [], ... "allowed_channels": [], - ... "allowed_category": [], + ... "allowed_categories": [], ... "default": True ... } @@ -402,7 +402,7 @@ class FilterChannelRangeViewSet(ModelViewSet): ... "disallowed_channels": [], ... "disallowed_categories": [], ... "allowed_channels": [], - ... "allowed_category": [], + ... "allowed_categories": [], ... "default": True ... } -- cgit v1.2.3 From d48d5ddbaa6b068d3a24f55ee7c8f3760006f04b Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 11 Jul 2021 14:46:16 +0800 Subject: Improve help text message. --- pydis_site/apps/api/models/bot/filters.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index c2f776d3..a2d3af6a 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -46,7 +46,7 @@ class FilterList(models.Model): name = models.CharField(max_length=50, help_text="The unique name of this list.") list_type = models.IntegerField( choices=FilterListType.choices, - help_text="Whenever this list is an allowlist or denylist" + help_text="Whether this list is an allowlist or denylist" ) filters = models.ManyToManyField("Filter", help_text="The content of this list.", default=[]) @@ -75,31 +75,31 @@ class FilterSettings(models.Model): validators=(validate_ping_field,), help_text="Who to ping when this filter triggers." ) - filter_dm = models.BooleanField(help_text="Whenever DMs should be filtered.") + filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.") dm_ping_type = ArrayField( models.CharField(max_length=20), validators=(validate_ping_field,), help_text="Who to ping when this filter triggers on a DM." ) delete_messages = models.BooleanField( - help_text="Whenever this filter should delete messages triggering it." + help_text="Whether this filter should delete messages triggering it." ) bypass_roles = ArrayField( models.BigIntegerField(), help_text="Roles and users who can bypass this filter." ) enabled = models.BooleanField( - help_text="Whenever ths filter is currently enabled." + help_text="Whether this filter is currently enabled." ) default_action = models.ForeignKey( "FilterAction", models.CASCADE, - help_text="The default action to perform." + help_text="What action to perform on the triggering user." ) default_range = models.ForeignKey( "ChannelRange", models.CASCADE, - help_text="Where does this filter apply." + help_text="The channels and categories in which this filter applies." ) -- cgit v1.2.3 From 6694ac4159c6d0f17451997df7f20b1363952ef3 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 11 Jul 2021 15:58:32 +0800 Subject: Fix faulty model enumeration. This also allows us to simplify the str dunder for a FilterList. --- pydis_site/apps/api/models/bot/filters.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index a2d3af6a..6f35bfb0 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -9,8 +9,8 @@ from django.db.models import UniqueConstraint class FilterListType(models.IntegerChoices): """Choice between allow or deny for a list type.""" - ALLOW: 1 - DENY: 0 + ALLOW = 1 + DENY = 0 class InfractionType(models.TextChoices): @@ -64,7 +64,7 @@ class FilterList(models.Model): ) def __str__(self) -> str: - return f"Filter {'allow' if self.list_type == 1 else 'deny'}list {self.name!r}" + return f"Filter {FilterListType(self.list_type).label}list {self.name!r}" class FilterSettings(models.Model): -- cgit v1.2.3 From 1095346a1f86e43d5d5c39045a54354d1290fe0e Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 11 Jul 2021 16:35:41 +0800 Subject: Improve name of dm sent to triggered user. --- pydis_site/apps/api/migrations/0070_new_filter_schema.py | 4 ++-- pydis_site/apps/api/models/bot/filters.py | 2 +- pydis_site/apps/api/serializers.py | 2 +- pydis_site/apps/api/tests/test_filters.py | 6 +++--- pydis_site/apps/api/viewsets/bot/filters.py | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index eb55e329..8580033a 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -23,7 +23,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: objects = filter_list_old.objects.filter(type=name) default_action = filter_action.objects.create( - user_dm=None, + dm_content=None, infraction_type=None, infraction_reason="", infraction_duration=None @@ -102,7 +102,7 @@ class Migration(migrations.Migration): name='FilterAction', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('user_dm', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), + ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)), ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 6f35bfb0..b5c80bda 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -106,7 +106,7 @@ class FilterSettings(models.Model): class FilterAction(models.Model): """The action to take when a filter is triggered.""" - user_dm = models.CharField( + dm_content = models.CharField( max_length=1000, null=True, help_text="The DM to send to a user triggering this filter." diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 54acf366..584d1f22 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -165,7 +165,7 @@ class FilterActionSerializer(ModelSerializer): """Metadata defined for the Django REST Framework.""" model = FilterAction - fields = ('id', 'user_dm', 'infraction_type', 'infraction_reason', 'infraction_duration') + fields = ('id', 'dm_content', 'infraction_type', 'infraction_reason', 'infraction_duration') class FilterChannelRangeSerializer(ModelSerializer): diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py index f38f3659..2df671e0 100644 --- a/pydis_site/apps/api/tests/test_filters.py +++ b/pydis_site/apps/api/tests/test_filters.py @@ -53,7 +53,7 @@ def get_test_sequences() -> Dict[str, TestSequence]: bypass_roles=[], enabled=False, default_action=FilterAction( - user_dm=None, + dm_content=None, infraction_type=None, infraction_reason="", infraction_duration=None @@ -80,7 +80,7 @@ def get_test_sequences() -> Dict[str, TestSequence]: "bypass_roles": [123456], "enabled": True, "default_action": FilterAction( - user_dm=None, + dm_content=None, infraction_type=None, infraction_reason="", infraction_duration=None @@ -98,7 +98,7 @@ def get_test_sequences() -> Dict[str, TestSequence]: FilterAction, "filteraction", { - "user_dm": "This is a DM message.", + "dm_content": "This is a DM message.", "infraction_type": "Mute", "infraction_reason": "Too long beard", "infraction_duration": "1 02:03:00" diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index e290fc65..9553fcac 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -264,7 +264,7 @@ class FilterActionViewSet(ModelViewSet): >>> [ ... { ... "id": 1, - ... "user_dm": "message", + ... "dm_content": "message", ... "infraction_type": "Warn", ... "infraction_reason": "", ... "infraction_duration": "01 12:34:56.123456" @@ -282,7 +282,7 @@ class FilterActionViewSet(ModelViewSet): #### Response format >>> { ... "id": 1, - ... "user_dm": "message", + ... "dm_content": "message", ... "infraction_type": "Warn", ... "infraction_reason": "", ... "infraction_duration": "01 12:34:56.123456" @@ -297,7 +297,7 @@ class FilterActionViewSet(ModelViewSet): #### Request body >>> { - ... "user_dm": "message", + ... "dm_content": "message", ... "infraction_type": "Warn", ... "infraction_reason": "", ... "infraction_duration": "01 12:34:56.123456" @@ -313,7 +313,7 @@ class FilterActionViewSet(ModelViewSet): #### Response format >>> { ... "id": 1, - ... "user_dm": "message", + ... "dm_content": "message", ... "infraction_type": "Warn", ... "infraction_reason": "", ... "infraction_duration": "01 12:34:56.123456" -- cgit v1.2.3 From 9ec355955895d5b26ce99aade3c0c6ccf913e6a4 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 11 Jul 2021 16:40:34 +0800 Subject: Migrate misc field names and help text changes. --- .../apps/api/migrations/0071_auto_20210711_0839.py | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 pydis_site/apps/api/migrations/0071_auto_20210711_0839.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py b/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py new file mode 100644 index 00000000..e1c45fb6 --- /dev/null +++ b/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py @@ -0,0 +1,44 @@ +# Generated by Django 3.0.14 on 2021-07-11 08:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0070_new_filter_schema'), + ] + + operations = [ + migrations.AlterField( + model_name='filterlist', + name='list_type', + field=models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist'), + ), + migrations.AlterField( + model_name='filtersettings', + name='default_action', + field=models.ForeignKey(help_text='What action to perform on the triggering user.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction'), + ), + migrations.AlterField( + model_name='filtersettings', + name='default_range', + field=models.ForeignKey(help_text='The channels and categories in which this filter applies.', on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange'), + ), + migrations.AlterField( + model_name='filtersettings', + name='delete_messages', + field=models.BooleanField(help_text='Whether this filter should delete messages triggering it.'), + ), + migrations.AlterField( + model_name='filtersettings', + name='enabled', + field=models.BooleanField(help_text='Whether this filter is currently enabled.'), + ), + migrations.AlterField( + model_name='filtersettings', + name='filter_dm', + field=models.BooleanField(help_text='Whether DMs should be filtered.'), + ), + ] -- cgit v1.2.3 From b082de6662e1b57f6831d219b44d95f93ed8a884 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Fri, 23 Jul 2021 18:58:35 +0800 Subject: Correct Filter-FilterList relationship. Instead of a many-many relationship, one filterlist has multiple filters. Nested serialization is read-only by default, so not all CRUD methods are implemented yet for the FilterList viewset. --- .../apps/api/migrations/0070_new_filter_schema.py | 11 ++-- pydis_site/apps/api/models/bot/filters.py | 6 +- pydis_site/apps/api/serializers.py | 22 ++++---- pydis_site/apps/api/tests/test_filters.py | 29 +++++++++- pydis_site/apps/api/viewsets/bot/filters.py | 64 +++++++--------------- 5 files changed, 68 insertions(+), 64 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index 8580033a..237ce7d7 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -54,17 +54,14 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: list_type=1 if type_ == "ALLOW" else 0 ) - new_objects = [] for object_ in objects: new_object = filter_.objects.create( content=object_.content, + filter_list = list_, description=object_.comment or "", additional_field=None, override=None ) new_object.save() - new_objects.append(new_object) - - list_.filters.add(*new_objects) class Migration(migrations.Migration): @@ -143,7 +140,6 @@ class Migration(migrations.Migration): ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), ('list_type', models.IntegerField(choices=[], help_text='Whenever this list is an allowlist or denylist')), ('default_settings', models.ForeignKey(help_text='Default parameters of this list.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterSettings')), - ('filters', models.ManyToManyField(help_text='The content of this list.', to='api.Filter', default=[])), ], ), migrations.AddField( @@ -151,6 +147,11 @@ class Migration(migrations.Migration): name='override', field=models.ForeignKey(help_text='Override the default settings.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.FilterOverride'), ), + 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'), diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index b5c80bda..99d6d5e4 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -48,8 +48,6 @@ class FilterList(models.Model): choices=FilterListType.choices, help_text="Whether this list is an allowlist or denylist" ) - - filters = models.ManyToManyField("Filter", help_text="The content of this list.", default=[]) default_settings = models.ForeignKey( "FilterSettings", models.CASCADE, @@ -152,6 +150,10 @@ class Filter(models.Model): content = models.CharField(max_length=100, help_text="The definition of this filter.") description = models.CharField(max_length=200, help_text="Why this filter has been added.") additional_field = models.BooleanField(null=True, help_text="Implementation specific field.") + filter_list = models.ForeignKey( + FilterList, models.CASCADE, related_name="filters", + help_text="The filter list containing this filter." + ) override = models.ForeignKey( "FilterOverride", models.SET_NULL, diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 584d1f22..afcf4d55 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -117,9 +117,21 @@ class DocumentationLinkSerializer(ModelSerializer): fields = ('package', 'base_url', 'inventory_url') +class FilterSerializer(ModelSerializer): + """A class providing (de-)serialization of `Filter` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = Filter + fields = ('id', 'content', 'description', 'additional_field', 'filter_list', 'override') + + class FilterListSerializer(ModelSerializer): """A class providing (de-)serialization of `FilterList` instances.""" + filters = FilterSerializer(many=True, read_only=True) + class Meta: """Metadata defined for the Django REST Framework.""" @@ -185,16 +197,6 @@ class FilterChannelRangeSerializer(ModelSerializer): ) -class FilterSerializer(ModelSerializer): - """A class providing (de-)serialization of `Filter` instances.""" - - class Meta: - """Metadata defined for the Django REST Framework.""" - - model = Filter - fields = ('id', 'content', 'description', 'additional_field', 'override') - - class FilterOverrideSerializer(ModelSerializer): """A class providing (de-)serialization of `FilterOverride` instances.""" diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py index 2df671e0..f694053d 100644 --- a/pydis_site/apps/api/tests/test_filters.py +++ b/pydis_site/apps/api/tests/test_filters.py @@ -32,7 +32,7 @@ FK_FIELDS: Dict[Type[Model], Tuple[str]] = { FilterSettings: ("default_action", "default_range"), FilterAction: (), ChannelRange: (), - Filter: (), + Filter: ("filter_list",), FilterOverride: ("filter_action", "filter_range") } @@ -122,7 +122,32 @@ def get_test_sequences() -> Dict[str, TestSequence]: "content": "bad word", "description": "This is a really bad word.", "additional_field": None, - "override": None + "override": None, + "filter_list": FilterList( + name="testname", + list_type=0, + default_settings=FilterSettings( + ping_type=[], + filter_dm=False, + dm_ping_type=[], + delete_messages=False, + bypass_roles=[], + enabled=False, + default_action=FilterAction( + dm_content=None, + infraction_type=None, + infraction_reason="", + infraction_duration=None + ), + default_range=ChannelRange( + disallowed_channels=[], + disallowed_categories=[], + allowed_channels=[], + allowed_categories=[], + default=False + ) + ) + ) } ), "filter_override": TestSequence( diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index 9553fcac..1b893f8c 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -20,7 +20,7 @@ from pydis_site.apps.api.serializers import ( # noqa: I101 - Preserving the fil class FilterListViewSet(ModelViewSet): """ - View providing CRUD operations on lists of items allowed or denied by our bot. + View providing GET/DELETE on lists of items allowed or denied by our bot. ## Routes ### GET /bot/filter/filter_lists @@ -33,8 +33,14 @@ class FilterListViewSet(ModelViewSet): ... "name": "guild_invite", ... "list_type": 1, ... "filters": [ - ... 1, - ... 2, + ... { + ... "id": 1, + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_field": None, + ... "override": 1, + ... "filter_list": 1 + ... }, ... ... ... ], ... "default_settings": 1 @@ -55,8 +61,14 @@ class FilterListViewSet(ModelViewSet): ... "name": "guild_invite", ... "list_type": 1, ... "filters": [ - ... 1, - ... 2, + ... { + ... "id": 1, + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_field": None, + ... "override": 1, + ... "filter_list": 1 + ... }, ... ... ... ], ... "default_settings": 1 @@ -66,45 +78,6 @@ class FilterListViewSet(ModelViewSet): - 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": "guild_invite", - ... "list_type": 1, - ... "filters": [ - ... 1, - ... 2, - ... ... - ... ], - ... "default_settings": 1 - ... } - - #### Status codes - - 201: returned on success - - 400: if one of the given fields is invalid - - ### PATCH /bot/filter/filter_lists/ - Updates a specific FilterList item from the database. - - #### Response format - >>> { - ... "id": 1, - ... "name": "guild_invite", - ... "list_type": 1, - ... "filters": [ - ... 1, - ... 2, - ... ... - ... ], - ... "default_settings": 1 - ... } - - #### Status codes - - 200: returned on success - - 400: if one of the given fields is invalid - ### DELETE /bot/filter/filter_lists/ Deletes the FilterList item with the given `id`. @@ -437,7 +410,8 @@ class FilterViewSet(ModelViewSet): ... "content": "267624335836053506", ... "description": "Python Discord", ... "additional_field": None, - ... "override": 1 + ... "override": 1, + ... "filter_list": 1 ... }, ... ... ... ] -- cgit v1.2.3 From 98d36f6fce899680fa10177556f06cc5357eb675 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 25 Sep 2021 13:04:39 +0300 Subject: Remove one-to-one relationships from filters tables --- pydis_site/apps/api/models/bot/filters.py | 153 ++++++++++++------------------ 1 file changed, 63 insertions(+), 90 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 99d6d5e4..68ac191b 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -1,3 +1,4 @@ +from abc import abstractmethod from typing import List from django.contrib.postgres.fields import ArrayField @@ -5,6 +6,8 @@ from django.core.exceptions import ValidationError from django.db import models from django.db.models import UniqueConstraint +from pydis_site.apps.api.models.mixins import AbstractModelMeta + class FilterListType(models.IntegerChoices): """Choice between allow or deny for a list type.""" @@ -40,70 +43,40 @@ def validate_ping_field(value_list: List[str]) -> None: raise ValidationError(f"{value!r} isn't a valid ping type.") -class FilterList(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" - ) - default_settings = models.ForeignKey( - "FilterSettings", - models.CASCADE, - help_text="Default parameters of this list." - ) - - 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 FilterSettingsMixin(models.Model, metaclass=AbstractModelMeta): + """Mixin for settings of a filter list.""" -class FilterSettings(models.Model): - """Persistent settings of a filter list.""" + @staticmethod + @abstractmethod + def allow_null() -> bool: + """Abstract property for allowing null values.""" ping_type = ArrayField( models.CharField(max_length=20), validators=(validate_ping_field,), - help_text="Who to ping when this filter triggers." + help_text="Who to ping when this filter triggers.", + null=allow_null.__func__() ) - filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.") + filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True) dm_ping_type = ArrayField( models.CharField(max_length=20), validators=(validate_ping_field,), - help_text="Who to ping when this filter triggers on a DM." + help_text="Who to ping when this filter triggers on a DM.", + null=allow_null.__func__() ) delete_messages = models.BooleanField( - help_text="Whether this filter should delete messages triggering it." + help_text="Whether this filter should delete messages triggering it.", + null=allow_null.__func__() ) bypass_roles = ArrayField( models.BigIntegerField(), - help_text="Roles and users who can bypass this filter." + help_text="Roles and users who can bypass this filter.", + null=allow_null.__func__() ) enabled = models.BooleanField( - help_text="Whether this filter is currently enabled." + help_text="Whether this filter is currently enabled.", + null=allow_null.__func__() ) - default_action = models.ForeignKey( - "FilterAction", - models.CASCADE, - help_text="What action to perform on the triggering user." - ) - default_range = models.ForeignKey( - "ChannelRange", - models.CASCADE, - help_text="The channels and categories in which this filter applies." - ) - - -class FilterAction(models.Model): - """The action to take when a filter is triggered.""" - dm_content = models.CharField( max_length=1000, null=True, @@ -124,27 +97,52 @@ class FilterAction(models.Model): help_text="The duration of the infraction. Null if permanent." ) - -class ChannelRange(models.Model): - """ - Where a filter should apply. - - The resolution is done in the following order: - - disallowed channels - - disallowed categories - - allowed categories - - allowed channels - - default - """ - + # Where a filter should apply. + # + # The resolution is done in the following order: + # - disallowed channels + # - disallowed categories + # - allowed categories + # - allowed channels + # - default disallowed_channels = ArrayField(models.IntegerField()) disallowed_categories = ArrayField(models.IntegerField()) allowed_channels = ArrayField(models.IntegerField()) allowed_categories = ArrayField(models.IntegerField()) default = models.BooleanField() + class Meta: + """Metaclass for settings mixin.""" + + abstract = True -class Filter(models.Model): + +class FilterList(FilterSettingsMixin): + """Represent a list in its allow or deny form.""" + + name = models.CharField(max_length=50, help_text="The unique name of this list.") + list_type = models.IntegerField( + choices=FilterListType.choices, + help_text="Whether this list is an allowlist or denylist" + ) + + @staticmethod + def allow_null() -> bool: + """Do not allow null values for default settings.""" + return False + + class Meta: + """Constrain name and list_type unique.""" + + constraints = ( + UniqueConstraint(fields=("name", "list_type"), name="unique_name_type"), + ) + + def __str__(self) -> str: + return f"Filter {FilterListType(self.list_type).label}list {self.name!r}" + + +class Filter(FilterSettingsMixin): """One specific trigger of a list.""" content = models.CharField(max_length=100, help_text="The definition of this filter.") @@ -154,36 +152,11 @@ class Filter(models.Model): FilterList, models.CASCADE, related_name="filters", help_text="The filter list containing this filter." ) - override = models.ForeignKey( - "FilterOverride", - models.SET_NULL, - null=True, - help_text="Override the default settings." - ) def __str__(self) -> str: return f"Filter {self.content!r}" - -class FilterOverride(models.Model): - """ - Setting overrides of a specific filter. - - Any non-null value will override the default ones. - """ - - ping_type = ArrayField( - models.CharField(max_length=20), - validators=(validate_ping_field,), null=True - ) - filter_dm = models.BooleanField(null=True) - dm_ping_type = ArrayField( - models.CharField(max_length=20), - validators=(validate_ping_field,), - null=True - ) - delete_messages = models.BooleanField(null=True) - bypass_roles = ArrayField(models.IntegerField(), null=True) - enabled = models.BooleanField(null=True) - filter_action = models.ForeignKey("FilterAction", models.CASCADE, null=True) - filter_range = models.ForeignKey("ChannelRange", models.CASCADE, null=True) + @staticmethod + def allow_null() -> bool: + """Allow null values for overrides.""" + return True -- cgit v1.2.3 From 08a52168dd3b0a9a366f5ca68c10437b83af5cf1 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 25 Sep 2021 13:05:52 +0300 Subject: Remove old one-to-one filters relationships serializers, views and URLs --- pydis_site/apps/api/models/__init__.py | 4 - pydis_site/apps/api/models/bot/__init__.py | 2 +- pydis_site/apps/api/serializers.py | 84 +---- pydis_site/apps/api/urls.py | 20 -- pydis_site/apps/api/viewsets/__init__.py | 4 - pydis_site/apps/api/viewsets/bot/__init__.py | 6 +- pydis_site/apps/api/viewsets/bot/filters.py | 450 +-------------------------- 7 files changed, 15 insertions(+), 555 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index 72f59b57..63087990 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -1,11 +1,7 @@ # flake8: noqa from .bot import ( FilterList, - FilterSettings, - FilterAction, - ChannelRange, Filter, - FilterOverride, BotSetting, DocumentationLink, DeletedMessage, diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index 1bfe0063..9ba763a4 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -1,5 +1,5 @@ # flake8: noqa -from .filters import FilterList, FilterSettings, FilterAction, ChannelRange, Filter, FilterOverride +from .filters import FilterList, Filter from .bot_setting import BotSetting from .deleted_message import DeletedMessage from .documentation_link import DocumentationLink diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index afcf4d55..ff2bd929 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -18,11 +18,7 @@ from .models import ( # noqa: I101 - Preserving the filter order DocumentationLink, Infraction, FilterList, - FilterSettings, - FilterAction, - ChannelRange, Filter, - FilterOverride, MessageDeletionContext, Nomination, NominationEntry, @@ -136,7 +132,18 @@ class FilterListSerializer(ModelSerializer): """Metadata defined for the Django REST Framework.""" model = FilterList - fields = ('id', 'name', 'list_type', 'filters', 'default_settings') + fields = ( + 'id', + 'name', + 'list_type', + 'filters', + 'ping_type', + 'filter_dm', + 'dm_ping_type', + 'delete_messages', + 'bypass_roles', + '' + ) # Ensure that we can only have one filter list with the same name and field validators = [ @@ -150,73 +157,6 @@ class FilterListSerializer(ModelSerializer): ] -class FilterSettingsSerializer(ModelSerializer): - """A class providing (de-)serialization of `FilterSettings` instances.""" - - class Meta: - """Metadata defined for the Django REST Framework.""" - - model = FilterSettings - fields = ( - 'id', - 'ping_type', - 'filter_dm', - 'dm_ping_type', - 'delete_messages', - 'bypass_roles', - 'enabled', - 'default_action', - 'default_range' - ) - - -class FilterActionSerializer(ModelSerializer): - """A class providing (de-)serialization of `FilterAction` instances.""" - - class Meta: - """Metadata defined for the Django REST Framework.""" - - model = FilterAction - fields = ('id', 'dm_content', 'infraction_type', 'infraction_reason', 'infraction_duration') - - -class FilterChannelRangeSerializer(ModelSerializer): - """A class providing (de-)serialization of `ChannelRange` instances.""" - - class Meta: - """Metadata defined for the Django REST Framework.""" - - model = ChannelRange - fields = ( - 'id', - 'disallowed_channels', - 'disallowed_categories', - 'allowed_channels', - 'allowed_categories', - 'default' - ) - - -class FilterOverrideSerializer(ModelSerializer): - """A class providing (de-)serialization of `FilterOverride` instances.""" - - class Meta: - """Metadata defined for the Django REST Framework.""" - - model = FilterOverride - fields = ( - 'id', - 'ping_type', - 'filter_dm', - 'dm_ping_type', - 'delete_messages', - 'bypass_roles', - 'enabled', - 'filter_action', - 'filter_range' - ) - - class InfractionSerializer(ModelSerializer): """A class providing (de-)serialization of `Infraction` instances.""" diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index 7af2e505..4e8edaf0 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -7,11 +7,7 @@ from .viewsets import ( # noqa: I101 - Preserving the filter order DeletedMessageViewSet, DocumentationLinkViewSet, FilterListViewSet, - FilterSettingsViewSet, - FilterActionViewSet, - FilterChannelRangeViewSet, FilterViewSet, - FilterOverrideViewSet, InfractionViewSet, NominationViewSet, OffTopicChannelNameViewSet, @@ -27,22 +23,6 @@ bot_router.register( 'filter/filter_lists', FilterListViewSet ) -bot_router.register( - 'filter/filter_settings', - FilterSettingsViewSet -) -bot_router.register( - 'filter/filter_action', - FilterActionViewSet -) -bot_router.register( - 'filter/channel_range', - FilterChannelRangeViewSet -) -bot_router.register( - 'filter/filter_override', - FilterOverrideViewSet -) bot_router.register( 'filter/filters', FilterViewSet diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index b3992d66..4cf4c655 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -5,11 +5,7 @@ from .bot import ( DocumentationLinkViewSet, InfractionViewSet, FilterListViewSet, - FilterSettingsViewSet, - FilterActionViewSet, - FilterChannelRangeViewSet, FilterViewSet, - FilterOverrideViewSet, NominationViewSet, OffensiveMessageViewSet, OffTopicChannelNameViewSet, diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py index 781624bd..4649fcde 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -1,11 +1,7 @@ # flake8: noqa from .filters import ( FilterListViewSet, - FilterSettingsViewSet, - FilterActionViewSet, - FilterChannelRangeViewSet, - FilterViewSet, - FilterOverrideViewSet + FilterViewSet ) from .bot_setting import BotSettingViewSet from .deleted_message import DeletedMessageViewSet diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index 1b893f8c..5b21de26 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -2,19 +2,11 @@ from rest_framework.viewsets import ModelViewSet from pydis_site.apps.api.models.bot.filters import ( # noqa: I101 - Preserving the filter order FilterList, - FilterSettings, - FilterAction, - ChannelRange, - Filter, - FilterOverride + Filter ) from pydis_site.apps.api.serializers import ( # noqa: I101 - Preserving the filter order FilterListSerializer, - FilterSettingsSerializer, - FilterActionSerializer, - FilterChannelRangeSerializer, FilterSerializer, - FilterOverrideSerializer ) @@ -90,311 +82,6 @@ class FilterListViewSet(ModelViewSet): queryset = FilterList.objects.all() -class FilterSettingsViewSet(ModelViewSet): - """ - View providing CRUD operations on settings of items allowed or denied by our bot. - - ## Routes - ### GET /bot/filter/filter_settings - Returns all FilterSettings items in the database. - - #### Response format - >>> [ - ... { - ... "id": 1, - ... "ping_type": [ - ... "onduty", - ... ... - ... ], - ... "filter_dm": True, - ... "dm_ping_type": [ - ... "onduty", - ... ... - ... ], - ... "delete_messages": True, - ... "bypass_roles": [ - ... 267630620367257601, - ... ... - ... ], - ... "enabled": True, - ... "default_action": 1, - ... "default_range": 1 - ... }, - ... ... - ... ] - - #### Status codes - - 200: returned on success - - 401: returned if unauthenticated - - ### GET /bot/filter/filter_settings/ - Returns a specific FilterSettings item from the database. - - #### Response format - >>> { - ... "id": 1, - ... "ping_type": [ - ... "onduty", - ... ... - ... ], - ... "filter_dm": True, - ... "dm_ping_type": [ - ... "onduty", - ... ... - ... ], - ... "delete_messages": True, - ... "bypass_roles": [ - ... 267630620367257601, - ... ... - ... ], - ... "enabled": True, - ... "default_action": 1, - ... "default_range": 1 - ... } - - #### Status codes - - 200: returned on success - - 404: returned if the id was not found. - - ### POST /bot/filter/filter_settings - Adds a single FilterSettings item to the database. - - #### Request body - >>> { - ... "ping_type": [ - ... "onduty", - ... ... - ... ], - ... "filter_dm": True, - ... "dm_ping_type": [ - ... "onduty", - ... ... - ... ], - ... "delete_messages": True, - ... "bypass_roles": [ - ... 267630620367257601, - ... ... - ... ], - ... "enabled": True, - ... "default_action": 1, - ... "default_range": 1 - ... } - - #### Status codes - - 201: returned on success - - 400: if one of the given fields is invalid - - ### PATCH /bot/filter/filter_settings/ - Updates a specific FilterSettings item from the database. - - #### Response format - >>> { - ... "id": 1, - ... "ping_type": [ - ... "onduty", - ... ... - ... ], - ... "filter_dm": True, - ... "dm_ping_type": [ - ... "onduty", - ... ... - ... ], - ... "delete_messages": True, - ... "bypass_roles": [ - ... 267630620367257601, - ... ... - ... ], - ... "enabled": True, - ... "default_action": 1, - ... "default_range": 1 - ... } - - #### Status codes - - 200: returned on success - - 400: if one of the given fields is invalid - - ### DELETE /bot/filter/filter_settings/ - Deletes the FilterSettings item with the given `id`. - - #### Status codes - - 204: returned on success - - 404: if a tag with the given `id` does not exist - """ - - serializer_class = FilterSettingsSerializer - queryset = FilterSettings.objects.all() - - -class FilterActionViewSet(ModelViewSet): - """ - View providing CRUD operations on actions taken by items allowed or denied by our bot. - - ## Routes - ### GET /bot/filter/filter_action - Returns all FilterAction items in the database. - - #### Response format - >>> [ - ... { - ... "id": 1, - ... "dm_content": "message", - ... "infraction_type": "Warn", - ... "infraction_reason": "", - ... "infraction_duration": "01 12:34:56.123456" - ... }, - ... ... - ... ] - - #### Status codes - - 200: returned on success - - 401: returned if unauthenticated - - ### GET /bot/filter/filter_action/ - Returns a specific FilterAction item from the database. - - #### Response format - >>> { - ... "id": 1, - ... "dm_content": "message", - ... "infraction_type": "Warn", - ... "infraction_reason": "", - ... "infraction_duration": "01 12:34:56.123456" - ... } - - #### Status codes - - 200: returned on success - - 404: returned if the id was not found. - - ### POST /bot/filter/filter_action - Adds a single FilterAction item to the database. - - #### Request body - >>> { - ... "dm_content": "message", - ... "infraction_type": "Warn", - ... "infraction_reason": "", - ... "infraction_duration": "01 12:34:56.123456" - ... } - - #### Status codes - - 201: returned on success - - 400: if one of the given fields is invalid - - ### PATCH /bot/filter/filter_action/ - Updates a specific FilterAction item from the database. - - #### Response format - >>> { - ... "id": 1, - ... "dm_content": "message", - ... "infraction_type": "Warn", - ... "infraction_reason": "", - ... "infraction_duration": "01 12:34:56.123456" - ... } - - #### Status codes - - 200: returned on success - - 400: if one of the given fields is invalid - - ### DELETE /bot/filter/filter_action/ - Deletes the FilterAction item with the given `id`. - - #### Status codes - - 204: returned on success - - 404: if a tag with the given `id` does not exist - """ - - serializer_class = FilterActionSerializer - queryset = FilterAction.objects.all() - - -class FilterChannelRangeViewSet(ModelViewSet): - """ - View providing CRUD operations on channels targeted by items allowed or denied by our bot. - - ## Routes - ### GET /bot/filter/channel_range - Returns all ChannelRange items in the database. - - #### Response format - >>> [ - ... { - ... "id": 1, - ... "disallowed_channels": [], - ... "disallowed_categories": [], - ... "allowed_channels": [], - ... "allowed_categories": [], - ... "default": True - ... }, - ... ... - ... ] - - #### Status codes - - 200: returned on success - - 401: returned if unauthenticated - - ### GET /bot/filter/channel_range/ - Returns a specific ChannelRange item from the database. - - #### Response format - >>> { - ... "id": 1, - ... "disallowed_channels": [], - ... "disallowed_categories": [], - ... "allowed_channels": [], - ... "allowed_categories": [], - ... "default": True - ... } - - #### Status codes - - 200: returned on success - - 404: returned if the id was not found. - - ### POST /bot/filter/channel_range - Adds a single ChannelRange item to the database. - - #### Request body - >>> { - ... "disallowed_channels": [], - ... "disallowed_categories": [], - ... "allowed_channels": [], - ... "allowed_categories": [], - ... "default": True - ... } - - #### Status codes - - 201: returned on success - - 400: if one of the given fields is invalid - - ### PATCH /bot/filter/channel_range/ - Updates a specific ChannelRange item from the database. - - #### Response format - >>> { - ... "id": 1, - ... "disallowed_channels": [], - ... "disallowed_categories": [], - ... "allowed_channels": [], - ... "allowed_categories": [], - ... "default": True - ... } - - #### Status codes - - 200: returned on success - - 400: if one of the given fields is invalid - - ### DELETE /bot/filter/channel_range/ - Deletes the ChannelRange item with the given `id`. - - #### Status codes - - 204: returned on success - - 404: if a tag with the given `id` does not exist - """ - - serializer_class = FilterChannelRangeSerializer - queryset = ChannelRange.objects.all() - - class FilterViewSet(ModelViewSet): """ View providing CRUD operations on items allowed or denied by our bot. @@ -477,138 +164,3 @@ class FilterViewSet(ModelViewSet): serializer_class = FilterSerializer queryset = Filter.objects.all() - - -class FilterOverrideViewSet(ModelViewSet): - """ - View providing CRUD operations setting overrides of items allowed or denied by our bot. - - ## Routes - ### GET /bot/filter/filter_override - Returns all FilterOverride items in the database. - - #### Response format - >>> [ - ... { - ... "id": 1, - ... "ping_type": [ - ... "onduty", - ... ... - ... ], - ... "filter_dm": True, - ... "dm_ping_type": [ - ... "onduty", - ... ... - ... ], - ... "delete_messages": True, - ... "bypass_roles": [ - ... 267630620367257601, - ... ... - ... ], - ... "enabled": True, - ... "filter_action": 1, - ... "filter_range": 1 - ... }, - ... ... - ... ] - - #### Status codes - - 200: returned on success - - 401: returned if unauthenticated - - ### GET /bot/filter/filter_override/ - Returns a specific FilterOverride item from the database. - - #### Response format - >>> { - ... "id": 1, - ... "ping_type": [ - ... "onduty", - ... ... - ... ], - ... "filter_dm": True, - ... "dm_ping_type": [ - ... "onduty", - ... ... - ... ], - ... "delete_messages": True, - ... "bypass_roles": [ - ... 267630620367257601, - ... ... - ... ], - ... "enabled": True, - ... "filter_action": 1, - ... "filter_range": 1 - ... } - - #### Status codes - - 200: returned on success - - 404: returned if the id was not found. - - ### POST /bot/filter/filter_override - Adds a single FilterOverride item to the database. - - #### Request body - >>> { - ... "ping_type": [ - ... "onduty", - ... ... - ... ], - ... "filter_dm": True, - ... "dm_ping_type": [ - ... "onduty", - ... ... - ... ], - ... "delete_messages": True, - ... "bypass_roles": [ - ... 267630620367257601, - ... ... - ... ], - ... "enabled": True, - ... "filter_action": 1, - ... "filter_range": 1 - ... } - - #### Status codes - - 201: returned on success - - 400: if one of the given fields is invalid - - ### PATCH /bot/filter/filter_override/ - Updates a specific FilterOverride item from the database. - - #### Response format - >>> { - ... "id": 1, - ... "ping_type": [ - ... "onduty", - ... ... - ... ], - ... "filter_dm": True, - ... "dm_ping_type": [ - ... "onduty", - ... ... - ... ], - ... "delete_messages": True, - ... "bypass_roles": [ - ... 267630620367257601, - ... ... - ... ], - ... "enabled": True, - ... "filter_action": 1, - ... "filter_range": 1 - ... } - - #### Status codes - - 200: returned on success - - 400: if one of the given fields is invalid - - ### DELETE /bot/filter/filter_override/ - Deletes the FilterOverride item with the given `id`. - - #### Status codes - - 204: returned on success - - 404: if a tag with the given `id` does not exist - """ - - serializer_class = FilterOverrideSerializer - queryset = FilterOverride.objects.all() -- cgit v1.2.3 From faf1948eb39f0389633a6f86f2d4e406f6e83b74 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 25 Sep 2021 13:06:20 +0300 Subject: Add AbstractModelMeta mixin --- pydis_site/apps/api/models/mixins.py | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/mixins.py b/pydis_site/apps/api/models/mixins.py index 5d75b78b..d32e6e72 100644 --- a/pydis_site/apps/api/models/mixins.py +++ b/pydis_site/apps/api/models/mixins.py @@ -1,3 +1,4 @@ +from abc import ABCMeta from operator import itemgetter from django.db import models @@ -29,3 +30,7 @@ class ModelTimestampMixin(models.Model): """Metaconfig for the mixin.""" abstract = True + + +class AbstractModelMeta(ABCMeta, type(models.Model)): + """Metaclass for ABCModel class.""" -- cgit v1.2.3 From 679472436bbb6250fab91d333c3e6fe3a20dea90 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 25 Sep 2021 13:06:38 +0300 Subject: Update filters migrations --- .../apps/api/migrations/0070_new_filter_schema.py | 129 +++++++++------------ .../apps/api/migrations/0071_auto_20210711_0839.py | 44 ------- 2 files changed, 53 insertions(+), 120 deletions(-) delete mode 100644 pydis_site/apps/api/migrations/0071_auto_20210711_0839.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index 237ce7d7..7925f5ff 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -14,7 +14,6 @@ OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_N 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_settings: pydis_site.apps.api.models.FilterSettings = apps.get_model("api", "FilterSettings") channel_range: pydis_site.apps.api.models.ChannelRange = apps.get_model("api", "ChannelRange") filter_action: pydis_site.apps.api.models.FilterAction = apps.get_model("api", "FilterAction") filter_list_old = apps.get_model("api", "FilterListOld") @@ -22,44 +21,47 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: for name, type_ in OLD_LIST_NAMES: objects = filter_list_old.objects.filter(type=name) - default_action = filter_action.objects.create( + list_ = filter_list.objects.create( + name=name.lower(), + list_type=1 if type_ == "ALLOW" else 0, + ping_type=["onduty"], + filter_dm=True, + dm_ping_type=["onduty"], + delete_messages=True, + bypass_roles=[267630620367257601], + enabled=False, dm_content=None, infraction_type=None, infraction_reason="", - infraction_duration=None - ) - default_action.save() - default_range = channel_range.objects.create( + infraction_duration=None, disallowed_channels=[], disallowed_categories=[], allowed_channels=[], allowed_categories=[], default=True ) - default_range.save() - default_settings = filter_settings.objects.create( - ping_type=["onduty"], - filter_dm=True, - dm_ping_type=["onduty"], - delete_messages=True, - bypass_roles=[267630620367257601], - enabled=False, - default_action=default_action, - default_range=default_range - ) - default_settings.save() - list_ = filter_list.objects.create( - name=name.lower(), - default_settings=default_settings, - list_type=1 if type_ == "ALLOW" else 0 - ) for object_ in objects: new_object = filter_.objects.create( content=object_.content, - filter_list = list_, + filter_list=list_, description=object_.comment or "", - additional_field=None, override=None + additional_field=None, + ping_type=None, + filter_dm=None, + dm_ping_type=None, + delete_messages=None, + bypass_roles=None, + enabled=None, + dm_content=None, + infraction_type=None, + infraction_reason="", + infraction_duration=None, + disallowed_channels=[], + disallowed_categories=[], + allowed_channels=[], + allowed_categories=[], + default=False ) new_object.save() @@ -75,17 +77,6 @@ class Migration(migrations.Migration): old_name='FilterList', new_name='FilterListOld' ), - migrations.CreateModel( - name='ChannelRange', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('default', models.BooleanField()), - ], - ), migrations.CreateModel( name='Filter', fields=[ @@ -93,60 +84,46 @@ class Migration(migrations.Migration): ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), ('description', models.CharField(help_text='Why this filter has been added.', max_length=200)), ('additional_field', models.BooleanField(help_text='Implementation specific field.', null=True)), - ], - ), - migrations.CreateModel( - name='FilterAction', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), + ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), + ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), + ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)), + ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), 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)), ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)), ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), + ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('default', models.BooleanField()), ], ), migrations.CreateModel( - name='FilterSettings', + name='FilterList', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), + ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')), ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), - ('filter_dm', models.BooleanField(help_text='Whenever DMs should be filtered.')), + ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')), ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), - ('delete_messages', models.BooleanField(help_text='Whenever this filter should delete messages triggering it.')), + ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')), ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None)), - ('enabled', models.BooleanField(help_text='Whenever ths filter is currently enabled.')), - ('default_action', models.ForeignKey(help_text='The default action to perform.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')), - ('default_range', models.ForeignKey(help_text='Where does this filter apply.', on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')), - ], - ), - migrations.CreateModel( - name='FilterOverride', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), null=True, size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), - ('filter_dm', models.BooleanField(null=True)), - ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), null=True, size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), - ('delete_messages', models.BooleanField(null=True)), - ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), - ('enabled', models.BooleanField(null=True)), - ('filter_action', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction')), - ('filter_range', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange')), - ], - ), - migrations.CreateModel( - name='FilterList', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), - ('list_type', models.IntegerField(choices=[], help_text='Whenever this list is an allowlist or denylist')), - ('default_settings', models.ForeignKey(help_text='Default parameters of this list.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterSettings')), + ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')), + ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), + ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)), + ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), + ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), + ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('default', models.BooleanField()), ], ), - migrations.AddField( - model_name='filter', - name='override', - field=models.ForeignKey(help_text='Override the default settings.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.FilterOverride'), - ), migrations.AddField( model_name='filter', name='filter_list', diff --git a/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py b/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py deleted file mode 100644 index e1c45fb6..00000000 --- a/pydis_site/apps/api/migrations/0071_auto_20210711_0839.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 3.0.14 on 2021-07-11 08:39 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0070_new_filter_schema'), - ] - - operations = [ - migrations.AlterField( - model_name='filterlist', - name='list_type', - field=models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist'), - ), - migrations.AlterField( - model_name='filtersettings', - name='default_action', - field=models.ForeignKey(help_text='What action to perform on the triggering user.', on_delete=django.db.models.deletion.CASCADE, to='api.FilterAction'), - ), - migrations.AlterField( - model_name='filtersettings', - name='default_range', - field=models.ForeignKey(help_text='The channels and categories in which this filter applies.', on_delete=django.db.models.deletion.CASCADE, to='api.ChannelRange'), - ), - migrations.AlterField( - model_name='filtersettings', - name='delete_messages', - field=models.BooleanField(help_text='Whether this filter should delete messages triggering it.'), - ), - migrations.AlterField( - model_name='filtersettings', - name='enabled', - field=models.BooleanField(help_text='Whether this filter is currently enabled.'), - ), - migrations.AlterField( - model_name='filtersettings', - name='filter_dm', - field=models.BooleanField(help_text='Whether DMs should be filtered.'), - ), - ] -- cgit v1.2.3 From 75a4b0eb57520b247ecaa228440b1abbd6c65845 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 25 Sep 2021 13:08:01 +0300 Subject: Remove default field from FilterSettingsMixin and migration --- pydis_site/apps/api/migrations/0070_new_filter_schema.py | 8 ++------ pydis_site/apps/api/models/bot/filters.py | 2 -- 2 files changed, 2 insertions(+), 8 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index 7925f5ff..c1db2a07 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -37,8 +37,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: disallowed_channels=[], disallowed_categories=[], allowed_channels=[], - allowed_categories=[], - default=True + allowed_categories=[] ) for object_ in objects: @@ -60,8 +59,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: disallowed_channels=[], disallowed_categories=[], allowed_channels=[], - allowed_categories=[], - default=False + allowed_categories=[] ) new_object.save() @@ -98,7 +96,6 @@ class Migration(migrations.Migration): ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('default', models.BooleanField()), ], ), migrations.CreateModel( @@ -121,7 +118,6 @@ class Migration(migrations.Migration): ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('default', models.BooleanField()), ], ), migrations.AddField( diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 68ac191b..365259e7 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -104,12 +104,10 @@ class FilterSettingsMixin(models.Model, metaclass=AbstractModelMeta): # - disallowed categories # - allowed categories # - allowed channels - # - default disallowed_channels = ArrayField(models.IntegerField()) disallowed_categories = ArrayField(models.IntegerField()) allowed_channels = ArrayField(models.IntegerField()) allowed_categories = ArrayField(models.IntegerField()) - default = models.BooleanField() class Meta: """Metaclass for settings mixin.""" -- cgit v1.2.3 From c5092f2895447b672dd9101a32997ce8a1c737e3 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 8 Oct 2021 19:21:36 +0300 Subject: Remove old models from migration --- pydis_site/apps/api/migrations/0070_new_filter_schema.py | 2 -- 1 file changed, 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index c1db2a07..aa114ca1 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -14,8 +14,6 @@ OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_N 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") - channel_range: pydis_site.apps.api.models.ChannelRange = apps.get_model("api", "ChannelRange") - filter_action: pydis_site.apps.api.models.FilterAction = apps.get_model("api", "FilterAction") filter_list_old = apps.get_model("api", "FilterListOld") for name, type_ in OLD_LIST_NAMES: -- cgit v1.2.3 From 25da18321e82f0a3cd18923d59d86b59acec160d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 28 Oct 2021 19:46:07 +0300 Subject: Update filters API to actually work --- .../apps/api/migrations/0070_new_filter_schema.py | 16 +-- pydis_site/apps/api/models/bot/filters.py | 129 ++++++++++++--------- pydis_site/apps/api/models/mixins.py | 5 - pydis_site/apps/api/serializers.py | 58 ++++++--- 4 files changed, 123 insertions(+), 85 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index aa114ca1..a595bda2 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -54,10 +54,10 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: infraction_type=None, infraction_reason="", infraction_duration=None, - disallowed_channels=[], - disallowed_categories=[], - allowed_channels=[], - allowed_categories=[] + disallowed_channels=None, + disallowed_categories=None, + allowed_channels=None, + allowed_categories=None ) new_object.save() @@ -90,10 +90,10 @@ class Migration(migrations.Migration): ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)), ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), - ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), + ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), + ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), + ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), + ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), ], ), migrations.CreateModel( diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 365259e7..b9a081e6 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -1,4 +1,3 @@ -from abc import abstractmethod from typing import List from django.contrib.postgres.fields import ArrayField @@ -6,8 +5,6 @@ from django.core.exceptions import ValidationError from django.db import models from django.db.models import UniqueConstraint -from pydis_site.apps.api.models.mixins import AbstractModelMeta - class FilterListType(models.IntegerChoices): """Choice between allow or deny for a list type.""" @@ -43,40 +40,9 @@ def validate_ping_field(value_list: List[str]) -> None: raise ValidationError(f"{value!r} isn't a valid ping type.") -class FilterSettingsMixin(models.Model, metaclass=AbstractModelMeta): - """Mixin for settings of a filter list.""" - - @staticmethod - @abstractmethod - def allow_null() -> bool: - """Abstract property for allowing null values.""" +class FilterSettingsMixin(models.Model): + """Mixin for common settings of a filters and filter lists.""" - ping_type = ArrayField( - models.CharField(max_length=20), - validators=(validate_ping_field,), - help_text="Who to ping when this filter triggers.", - null=allow_null.__func__() - ) - filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True) - dm_ping_type = ArrayField( - models.CharField(max_length=20), - validators=(validate_ping_field,), - help_text="Who to ping when this filter triggers on a DM.", - null=allow_null.__func__() - ) - delete_messages = models.BooleanField( - help_text="Whether this filter should delete messages triggering it.", - null=allow_null.__func__() - ) - bypass_roles = ArrayField( - models.BigIntegerField(), - help_text="Roles and users who can bypass this filter.", - null=allow_null.__func__() - ) - enabled = models.BooleanField( - help_text="Whether this filter is currently enabled.", - null=allow_null.__func__() - ) dm_content = models.CharField( max_length=1000, null=True, @@ -97,18 +63,6 @@ class FilterSettingsMixin(models.Model, metaclass=AbstractModelMeta): help_text="The duration of the infraction. Null if permanent." ) - # Where a filter should apply. - # - # The resolution is done in the following order: - # - disallowed channels - # - disallowed categories - # - allowed categories - # - allowed channels - disallowed_channels = ArrayField(models.IntegerField()) - disallowed_categories = ArrayField(models.IntegerField()) - allowed_channels = ArrayField(models.IntegerField()) - allowed_categories = ArrayField(models.IntegerField()) - class Meta: """Metaclass for settings mixin.""" @@ -123,11 +77,43 @@ class FilterList(FilterSettingsMixin): choices=FilterListType.choices, help_text="Whether this list is an allowlist or denylist" ) - - @staticmethod - def allow_null() -> bool: - """Do not allow null values for default settings.""" - return False + ping_type = ArrayField( + models.CharField(max_length=20), + validators=(validate_ping_field,), + help_text="Who to ping when this filter triggers.", + null=False + ) + filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False) + dm_ping_type = ArrayField( + models.CharField(max_length=20), + validators=(validate_ping_field,), + help_text="Who to ping when this filter triggers on a DM.", + null=False + ) + delete_messages = models.BooleanField( + help_text="Whether this filter should delete messages triggering it.", + null=False + ) + bypass_roles = ArrayField( + models.BigIntegerField(), + 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 + ) + # Where a filter should apply. + # + # The resolution is done in the following order: + # - disallowed channels + # - disallowed categories + # - allowed categories + # - allowed channels + disallowed_channels = ArrayField(models.IntegerField()) + disallowed_categories = ArrayField(models.IntegerField()) + allowed_channels = ArrayField(models.IntegerField()) + allowed_categories = ArrayField(models.IntegerField()) class Meta: """Constrain name and list_type unique.""" @@ -150,11 +136,38 @@ class Filter(FilterSettingsMixin): FilterList, models.CASCADE, related_name="filters", help_text="The filter list containing this filter." ) + ping_type = ArrayField( + models.CharField(max_length=20), + validators=(validate_ping_field,), + help_text="Who to ping when this filter triggers.", + null=True + ) + filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True) + dm_ping_type = ArrayField( + models.CharField(max_length=20), + validators=(validate_ping_field,), + help_text="Who to ping when this filter triggers on a DM.", + null=True + ) + delete_messages = models.BooleanField( + help_text="Whether this filter should delete messages triggering it.", + null=True + ) + bypass_roles = ArrayField( + models.BigIntegerField(), + 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 + ) + + # Check FilterList model for information about these properties. + disallowed_channels = ArrayField(models.IntegerField(), null=True) + disallowed_categories = ArrayField(models.IntegerField(), null=True) + allowed_channels = ArrayField(models.IntegerField(), null=True) + allowed_categories = ArrayField(models.IntegerField(), null=True) def __str__(self) -> str: return f"Filter {self.content!r}" - - @staticmethod - def allow_null() -> bool: - """Allow null values for overrides.""" - return True diff --git a/pydis_site/apps/api/models/mixins.py b/pydis_site/apps/api/models/mixins.py index d32e6e72..5d75b78b 100644 --- a/pydis_site/apps/api/models/mixins.py +++ b/pydis_site/apps/api/models/mixins.py @@ -1,4 +1,3 @@ -from abc import ABCMeta from operator import itemgetter from django.db import models @@ -30,7 +29,3 @@ class ModelTimestampMixin(models.Model): """Metaconfig for the mixin.""" abstract = True - - -class AbstractModelMeta(ABCMeta, type(models.Model)): - """Metaclass for ABCModel class.""" diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index ff2bd929..4e92b3a0 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -113,6 +113,29 @@ class DocumentationLinkSerializer(ModelSerializer): fields = ('package', 'base_url', 'inventory_url') +ALWAYS_OPTIONAL_SETTINGS = ( + 'dm_content', + 'infraction_type', + 'infraction_reason', + 'infraction_duration', +) + +REQUIRED_FOR_FILTER_LIST_SETTINGS = ( + 'ping_type', + 'filter_dm', + 'dm_ping_type', + 'delete_messages', + 'bypass_roles', + 'enabled', + 'disallowed_channels', + 'disallowed_categories', + 'allowed_channels', + 'allowed_categories', +) + +SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS + + class FilterSerializer(ModelSerializer): """A class providing (de-)serialization of `Filter` instances.""" @@ -120,7 +143,16 @@ class FilterSerializer(ModelSerializer): """Metadata defined for the Django REST Framework.""" model = Filter - fields = ('id', 'content', 'description', 'additional_field', 'filter_list', 'override') + fields = ('id', 'content', 'description', 'additional_field', 'filter_list') + SETTINGS_FIELDS + extra_kwargs = { + field: {'required': False, 'allow_null': True} for field in SETTINGS_FIELDS + } | { + 'infraction_reason': {'allow_blank': True, 'allow_null': True, 'required': False}, + 'disallowed_channels': {'allow_empty': True, 'allow_null': True, 'required': False}, + 'disallowed_categories': {'allow_empty': True, 'allow_null': True, 'required': False}, + 'allowed_channels': {'allow_empty': True, 'allow_null': True, 'required': False}, + 'allowed_categories': {'allow_empty': True, 'allow_null': True, 'required': False}, + } class FilterListSerializer(ModelSerializer): @@ -132,18 +164,16 @@ class FilterListSerializer(ModelSerializer): """Metadata defined for the Django REST Framework.""" model = FilterList - fields = ( - 'id', - 'name', - 'list_type', - 'filters', - 'ping_type', - 'filter_dm', - 'dm_ping_type', - 'delete_messages', - 'bypass_roles', - '' - ) + fields = ('id', 'name', 'list_type', 'filters') + SETTINGS_FIELDS + extra_kwargs = { + field: {'required': False, 'allow_null': True} for field in ALWAYS_OPTIONAL_SETTINGS + } | { + 'infraction_reason': {'allow_blank': True, 'allow_null': True, 'required': False}, + 'disallowed_channels': {'allow_empty': True}, + 'disallowed_categories': {'allow_empty': True}, + 'allowed_channels': {'allow_empty': True}, + 'allowed_categories': {'allow_empty': True}, + } # Ensure that we can only have one filter list with the same name and field validators = [ @@ -200,7 +230,7 @@ class InfractionSerializer(ModelSerializer): if hidden and infr_type in ('superstar', 'warning', 'voice_ban'): raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']}) - if not hidden and infr_type in ('note', ): + if not hidden and infr_type in ('note',): raise ValidationError({'hidden': [f'{infr_type} infractions must be hidden.']}) return attrs -- cgit v1.2.3 From 4c2eaff72ba9e95e1ef8d7b40396187783d87a50 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 28 Oct 2021 20:15:36 +0300 Subject: Add basic validation for infraction fields + use common infraction types --- pydis_site/apps/api/models/bot/filters.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index b9a081e6..eebcf703 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError from django.db import models from django.db.models import UniqueConstraint +from pydis_site.apps.api.models import Infraction + class FilterListType(models.IntegerChoices): """Choice between allow or deny for a list type.""" @@ -13,16 +15,6 @@ class FilterListType(models.IntegerChoices): DENY = 0 -class InfractionType(models.TextChoices): - """Possible type of infractions.""" - - NOTE = "Note" - WARN = "Warn" - MUTE = "Mute" - KICK = "Kick" - BAN = "Ban" - - # Valid special values in ping related fields VALID_PINGS = ("everyone", "here", "moderators", "onduty", "admins") @@ -49,7 +41,7 @@ class FilterSettingsMixin(models.Model): help_text="The DM to send to a user triggering this filter." ) infraction_type = models.CharField( - choices=InfractionType.choices, + choices=Infraction.TYPE_CHOICES, max_length=4, null=True, help_text="The infraction to apply to this user." @@ -63,6 +55,11 @@ class FilterSettingsMixin(models.Model): help_text="The duration of the infraction. Null if permanent." ) + def clean(self): + """Validate infraction fields as whole.""" + if (self.infraction_duration or self.infraction_reason) and not self.infraction_type: + raise ValidationError("Infraction type is required if setting infraction duration or reason.") + class Meta: """Metaclass for settings mixin.""" -- cgit v1.2.3 From d8ad1bdbcfcc8a0881c0ceb4d7d486455d23e170 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 28 Oct 2021 20:31:09 +0300 Subject: Add validation to filters to not allow duplicated channels and categories --- pydis_site/apps/api/models/bot/filters.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index eebcf703..45dea2c4 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -112,6 +112,20 @@ class FilterList(FilterSettingsMixin): allowed_channels = ArrayField(models.IntegerField()) allowed_categories = ArrayField(models.IntegerField()) + def clean(self): + """Do not allow duplicates in allowed and disallowed lists.""" + # Still run infraction fields validation + super().clean() + + channels_collection = self.allowed_channels + self.disallowed_channels + categories_collection = self.allowed_categories + self.disallowed_categories + + if len(channels_collection) != len(set(channels_collection)): + raise ValidationError("Allowed and disallowed channels lists contain duplicates.") + + if len(categories_collection) != len(set(categories_collection)): + raise ValidationError("Allowed and disallowed categories lists contain duplicates.") + class Meta: """Constrain name and list_type unique.""" @@ -166,5 +180,20 @@ class Filter(FilterSettingsMixin): allowed_channels = ArrayField(models.IntegerField(), null=True) allowed_categories = ArrayField(models.IntegerField(), null=True) + def clean(self): + """Do not allow duplicates in allowed and disallowed lists.""" + # Still run infraction fields validation + super().clean() + + if self.allowed_channels is not None or self.disallowed_channels is not None: + channels_collection = self.allowed_channels + self.disallowed_channels + if len(channels_collection) != len(set(channels_collection)): + raise ValidationError("Allowed and disallowed channels lists contain duplicates.") + + if self.allowed_categories is not None or self.disallowed_categories is not None: + categories_collection = self.allowed_categories + self.disallowed_categories + if len(categories_collection) != len(set(categories_collection)): + raise ValidationError("Allowed and disallowed categories lists contain duplicates.") + def __str__(self) -> str: return f"Filter {self.content!r}" -- cgit v1.2.3 From f4152448dfa4cd9912c22134af01fe37f0b153f6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 28 Oct 2021 20:38:59 +0300 Subject: Add validation to filters to not allow duplicates + additional_field -> JSON --- pydis_site/apps/api/migrations/0070_new_filter_schema.py | 6 +++--- pydis_site/apps/api/models/bot/filters.py | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index a595bda2..8716cbad 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -79,7 +79,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), ('description', models.CharField(help_text='Why this filter has been added.', max_length=200)), - ('additional_field', models.BooleanField(help_text='Implementation specific field.', null=True)), + ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)), ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), @@ -87,7 +87,7 @@ class Migration(migrations.Migration): ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), 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)), - ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)), + ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), @@ -109,7 +109,7 @@ class Migration(migrations.Migration): ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None)), ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')), ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), - ('infraction_type', models.CharField(choices=[('Note', 'Note'), ('Warn', 'Warn'), ('Mute', 'Mute'), ('Kick', 'Kick'), ('Ban', 'Ban')], help_text='The infraction to apply to this user.', max_length=4, null=True)), + ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 45dea2c4..472354f8 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -1,11 +1,12 @@ from typing import List -from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.fields import ArrayField, JSONField from django.core.exceptions import ValidationError from django.db import models from django.db.models import UniqueConstraint -from pydis_site.apps.api.models import Infraction +# Must be imported that way to avoid circular imports +from .infraction import Infraction class FilterListType(models.IntegerChoices): @@ -42,7 +43,7 @@ class FilterSettingsMixin(models.Model): ) infraction_type = models.CharField( choices=Infraction.TYPE_CHOICES, - max_length=4, + max_length=9, null=True, help_text="The infraction to apply to this user." ) @@ -142,7 +143,7 @@ class Filter(FilterSettingsMixin): content = models.CharField(max_length=100, help_text="The definition of this filter.") description = models.CharField(max_length=200, help_text="Why this filter has been added.") - additional_field = models.BooleanField(null=True, help_text="Implementation specific field.") + additional_field = JSONField(null=True, help_text="Implementation specific field.") filter_list = models.ForeignKey( FilterList, models.CASCADE, related_name="filters", help_text="The filter list containing this filter." -- cgit v1.2.3 From b49612db59ca075f64a4c7da11e3c9ce7e7b19eb Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 29 Oct 2021 20:11:27 +0300 Subject: Move filters validations to serializers --- pydis_site/apps/api/models/bot/filters.py | 34 ------------------------------- pydis_site/apps/api/serializers.py | 33 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 34 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 472354f8..3a1f3c6a 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -56,11 +56,6 @@ class FilterSettingsMixin(models.Model): help_text="The duration of the infraction. Null if permanent." ) - def clean(self): - """Validate infraction fields as whole.""" - if (self.infraction_duration or self.infraction_reason) and not self.infraction_type: - raise ValidationError("Infraction type is required if setting infraction duration or reason.") - class Meta: """Metaclass for settings mixin.""" @@ -113,20 +108,6 @@ class FilterList(FilterSettingsMixin): allowed_channels = ArrayField(models.IntegerField()) allowed_categories = ArrayField(models.IntegerField()) - def clean(self): - """Do not allow duplicates in allowed and disallowed lists.""" - # Still run infraction fields validation - super().clean() - - channels_collection = self.allowed_channels + self.disallowed_channels - categories_collection = self.allowed_categories + self.disallowed_categories - - if len(channels_collection) != len(set(channels_collection)): - raise ValidationError("Allowed and disallowed channels lists contain duplicates.") - - if len(categories_collection) != len(set(categories_collection)): - raise ValidationError("Allowed and disallowed categories lists contain duplicates.") - class Meta: """Constrain name and list_type unique.""" @@ -181,20 +162,5 @@ class Filter(FilterSettingsMixin): allowed_channels = ArrayField(models.IntegerField(), null=True) allowed_categories = ArrayField(models.IntegerField(), null=True) - def clean(self): - """Do not allow duplicates in allowed and disallowed lists.""" - # Still run infraction fields validation - super().clean() - - if self.allowed_channels is not None or self.disallowed_channels is not None: - channels_collection = self.allowed_channels + self.disallowed_channels - if len(channels_collection) != len(set(channels_collection)): - raise ValidationError("Allowed and disallowed channels lists contain duplicates.") - - if self.allowed_categories is not None or self.disallowed_categories is not None: - categories_collection = self.allowed_categories + self.disallowed_categories - if len(categories_collection) != len(set(categories_collection)): - raise ValidationError("Allowed and disallowed categories lists contain duplicates.") - def __str__(self) -> str: return f"Filter {self.content!r}" diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 4e92b3a0..b5f083b0 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -139,6 +139,23 @@ SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS class FilterSerializer(ModelSerializer): """A class providing (de-)serialization of `Filter` instances.""" + def validate(self, data): + """Perform infraction data + allow and disallowed lists validation.""" + if (data.get('infraction_reason') or data.get('infraction_duration')) and not data.get('infraction_type'): + raise ValidationError("Infraction type is required with infraction duration or reason") + + if data.get('allowed_channels') is not None and data.get('disallowed_channels') is not None: + channels_collection = data['allowed_channels'] + data['disallowed_channels'] + if len(channels_collection) != len(set(channels_collection)): + raise ValidationError("Allowed and disallowed channels lists contain duplicates.") + + if data.get('allowed_categories') is not None and data.get('disallowed_categories') is not None: + categories_collection = data['allowed_categories'] + data['disallowed_categories'] + if len(categories_collection) != len(set(categories_collection)): + raise ValidationError("Allowed and disallowed categories lists contain duplicates.") + + return data + class Meta: """Metadata defined for the Django REST Framework.""" @@ -160,6 +177,22 @@ class FilterListSerializer(ModelSerializer): filters = FilterSerializer(many=True, read_only=True) + def validate(self, data): + """Perform infraction data + allow and disallowed lists validation.""" + if (data['infraction_reason'] or data['infraction_duration']) and not data['infraction_type']: + raise ValidationError("Infraction type is required with infraction duration or reason") + + channels_collection = data['allowed_channels'] + data['disallowed_channels'] + categories_collection = data['allowed_categories'] + data['disallowed_categories'] + + if len(channels_collection) != len(set(channels_collection)): + raise ValidationError("Allowed and disallowed channels lists contain duplicates.") + + if len(categories_collection) != len(set(categories_collection)): + raise ValidationError("Allowed and disallowed categories lists contain duplicates.") + + return data + class Meta: """Metadata defined for the Django REST Framework.""" -- cgit v1.2.3 From 55d9288f11e2981eb9251f92164a597869c07cf9 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 29 Oct 2021 20:11:38 +0300 Subject: Add merge migration --- pydis_site/apps/api/migrations/0074_merge_20211017_0822.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 pydis_site/apps/api/migrations/0074_merge_20211017_0822.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py b/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py new file mode 100644 index 00000000..ae41ac71 --- /dev/null +++ b/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.14 on 2021-10-17 08:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0073_otn_allow_GT_and_LT'), + ('api', '0070_new_filter_schema'), + ] + + operations = [ + ] -- cgit v1.2.3 From 7d21797b8bc14b92a48bc782694e226b2562c1b5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 29 Oct 2021 20:17:03 +0300 Subject: Fix linting --- pydis_site/apps/api/serializers.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index b5f083b0..c82b0797 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -139,17 +139,25 @@ SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS class FilterSerializer(ModelSerializer): """A class providing (de-)serialization of `Filter` instances.""" - def validate(self, data): + def validate(self, data: dict) -> dict: """Perform infraction data + allow and disallowed lists validation.""" - if (data.get('infraction_reason') or data.get('infraction_duration')) and not data.get('infraction_type'): + if ( + data.get('infraction_reason') or data.get('infraction_duration') + ) and not data.get('infraction_type'): raise ValidationError("Infraction type is required with infraction duration or reason") - if data.get('allowed_channels') is not None and data.get('disallowed_channels') is not None: + if ( + data.get('allowed_channels') is not None + and data.get('disallowed_channels') is not None + ): channels_collection = data['allowed_channels'] + data['disallowed_channels'] if len(channels_collection) != len(set(channels_collection)): raise ValidationError("Allowed and disallowed channels lists contain duplicates.") - if data.get('allowed_categories') is not None and data.get('disallowed_categories') is not None: + if ( + data.get('allowed_categories') is not None + and data.get('disallowed_categories') is not None + ): categories_collection = data['allowed_categories'] + data['disallowed_categories'] if len(categories_collection) != len(set(categories_collection)): raise ValidationError("Allowed and disallowed categories lists contain duplicates.") @@ -160,7 +168,9 @@ class FilterSerializer(ModelSerializer): """Metadata defined for the Django REST Framework.""" model = Filter - fields = ('id', 'content', 'description', 'additional_field', 'filter_list') + SETTINGS_FIELDS + fields = ( + 'id', 'content', 'description', 'additional_field', 'filter_list' + ) + SETTINGS_FIELDS extra_kwargs = { field: {'required': False, 'allow_null': True} for field in SETTINGS_FIELDS } | { @@ -177,9 +187,11 @@ class FilterListSerializer(ModelSerializer): filters = FilterSerializer(many=True, read_only=True) - def validate(self, data): + def validate(self, data: dict) -> dict: """Perform infraction data + allow and disallowed lists validation.""" - if (data['infraction_reason'] or data['infraction_duration']) and not data['infraction_type']: + if ( + data['infraction_reason'] or data['infraction_duration'] + ) and not data['infraction_type']: raise ValidationError("Infraction type is required with infraction duration or reason") channels_collection = data['allowed_channels'] + data['disallowed_channels'] -- cgit v1.2.3 From 8ab32d7820b57b9f3edb61d4bd93864b6037502b Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Sun, 5 Dec 2021 16:09:56 +0100 Subject: Adjust Filter JSON Schema From now on the Serializer will have a different JSON representation than the table schema itself, conforming to the format needed on the bot-side. --- pydis_site/apps/api/serializers.py | 30 +++++++ pydis_site/apps/api/viewsets/bot/filters.py | 127 +++++++++++++++++++++------- 2 files changed, 127 insertions(+), 30 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index c82b0797..864ab52e 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -133,6 +133,17 @@ REQUIRED_FOR_FILTER_LIST_SETTINGS = ( 'allowed_categories', ) +# Required fields for custom JSON representation purposes +BASE_FIELDS = ('id', 'content', 'description', 'additional_field') +BASE_SETTINGS_FIELDS = ("ping_type", "dm_ping_type", "bypass_roles", "filter_dm") +INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration") +CHANNEL_SCOPE_FIELDS = ( + "allowed_channels", + "allowed_categories", + "disallowed_channels", + "disallowed_categories" +) + SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS @@ -181,6 +192,25 @@ class FilterSerializer(ModelSerializer): 'allowed_categories': {'allow_empty': True, 'allow_null': True, 'required': False}, } + def to_representation(self, instance: Filter) -> dict: + """ + Provides a custom JSON representation to the Filter Serializers + + That does not affect how the Serializer works in general. + """ + item = Filter.objects.get(id=instance.id) + schema_settings = { + "settings": + {name: getattr(item, name) for name in BASE_SETTINGS_FIELDS} + | {"infraction": {name: getattr(item, name) for name in INFRACTION_FIELDS}} + | {"channel_scope": {name: getattr(item, name) for name in CHANNEL_SCOPE_FIELDS}} + } + + schema_base = {name: getattr(item, name) for name in BASE_FIELDS} | \ + {"filter_list": item.filter_list.id} + + return schema_base | schema_settings + class FilterListSerializer(ModelSerializer): """A class providing (de-)serialization of `FilterList` instances.""" diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index 5b21de26..64329ebe 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -27,11 +27,28 @@ class FilterListViewSet(ModelViewSet): ... "filters": [ ... { ... "id": 1, + ... "filter_list": 1 ... "content": "267624335836053506", ... "description": "Python Discord", ... "additional_field": None, - ... "override": 1, - ... "filter_list": 1 + ... "settings": { + ... "ping_type": None, + ... "dm_ping_type": None + ... "bypass_roles": None + ... "filter_dm": None, + ... "infraction": { + ... "infraction_type": None, + ... "infraction_reason": "", + ... "infraction_duration": None + ... }, + ... "channel_scope": { + ... "allowed_channels": None, + ... "allowed_categories": None, + ... "disallowed_channels": None, + ... "disallowed_categories": None + ... } + ... } + ... ... }, ... ... ... ], @@ -48,23 +65,40 @@ class FilterListViewSet(ModelViewSet): Returns a specific FilterList item from the database. #### Response format - >>> { - ... "id": 1, - ... "name": "guild_invite", - ... "list_type": 1, - ... "filters": [ - ... { - ... "id": 1, - ... "content": "267624335836053506", - ... "description": "Python Discord", - ... "additional_field": None, - ... "override": 1, - ... "filter_list": 1 - ... }, - ... ... - ... ], - ... "default_settings": 1 - ... } + ... { + ... "id": 1, + ... "name": "guild_invite", + ... "list_type": 1, + ... "filters": [ + ... { + ... "id": 1, + ... "filter_list": 1 + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_field": None, + ... "settings": { + ... "ping_type": None, + ... "dm_ping_type": None + ... "bypass_roles": None + ... "filter_dm": None, + ... "infraction": { + ... "infraction_type": None, + ... "infraction_reason": "", + ... "infraction_duration": None + ... }, + ... "channel_scope": { + ... "allowed_channels": None, + ... "allowed_categories": None, + ... "disallowed_channels": None, + ... "disallowed_categories": None + ... } + ... } + ... + ... }, + ... ... + ... ], + ... "default_settings": 1 + ... } #### Status codes - 200: returned on success @@ -93,12 +127,28 @@ class FilterViewSet(ModelViewSet): #### Response format >>> [ ... { - ... "id": 1, - ... "content": "267624335836053506", - ... "description": "Python Discord", - ... "additional_field": None, - ... "override": 1, - ... "filter_list": 1 + ... "id": 1, + ... "filter_list": 1 + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_field": None, + ... "settings": { + ... "ping_type": None, + ... "dm_ping_type": None + ... "bypass_roles": None + ... "filter_dm": None, + ... "infraction": { + ... "infraction_type": None, + ... "infraction_reason": "", + ... "infraction_duration": None + ... }, + ... "channel_scope": { + ... "allowed_channels": None, + ... "allowed_categories": None, + ... "disallowed_channels": None, + ... "disallowed_categories": None + ... } + ... } ... }, ... ... ... ] @@ -112,11 +162,28 @@ class FilterViewSet(ModelViewSet): #### Response format >>> { - ... "id": 1, - ... "content": "267624335836053506", - ... "description": "Python Discord", - ... "additional_field": None, - ... "override": 1 + ... "id": 1, + ... "filter_list": 1 + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_field": None, + ... "settings": { + ... "ping_type": None, + ... "dm_ping_type": None + ... "bypass_roles": None + ... "filter_dm": None, + ... "infraction": { + ... "infraction_type": None, + ... "infraction_reason": "", + ... "infraction_duration": None + ... }, + ... "channel_scope": { + ... "allowed_channels": None, + ... "allowed_categories": None, + ... "disallowed_channels": None, + ... "disallowed_categories": None + ... } + ... } ... } #### Status codes -- cgit v1.2.3 From e3a45e09041898ffd0bccd3c730524e8c673e696 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 6 Dec 2021 20:22:01 +0100 Subject: Adjust FilterList Representation From now on the FilterList Serializer will contain a settings field with all the settings that were listed previously, on the model. --- pydis_site/apps/api/serializers.py | 17 ++++++++-- pydis_site/apps/api/viewsets/bot/filters.py | 49 +++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 6 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 864ab52e..267cf761 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -181,7 +181,7 @@ class FilterSerializer(ModelSerializer): model = Filter fields = ( 'id', 'content', 'description', 'additional_field', 'filter_list' - ) + SETTINGS_FIELDS + ) extra_kwargs = { field: {'required': False, 'allow_null': True} for field in SETTINGS_FIELDS } | { @@ -194,7 +194,8 @@ class FilterSerializer(ModelSerializer): def to_representation(self, instance: Filter) -> dict: """ - Provides a custom JSON representation to the Filter Serializers + + Provides a custom JSON representation to the Filter Serializers. That does not affect how the Serializer works in general. """ @@ -239,7 +240,7 @@ class FilterListSerializer(ModelSerializer): """Metadata defined for the Django REST Framework.""" model = FilterList - fields = ('id', 'name', 'list_type', 'filters') + SETTINGS_FIELDS + fields = ('id', 'name', 'list_type', 'filters') extra_kwargs = { field: {'required': False, 'allow_null': True} for field in ALWAYS_OPTIONAL_SETTINGS } | { @@ -261,6 +262,16 @@ class FilterListSerializer(ModelSerializer): ), ] + def to_representation(self, instance: FilterList) -> dict: + """ + Provides a custom JSON representation to the FilterList Serializers. + + That does not affect how the Serializer works in general. + """ + ret = super().to_representation(instance) + ret["settings"] = {name: getattr(instance, name) for name in SETTINGS_FIELDS} + return ret + class InfractionSerializer(ModelSerializer): """A class providing (de-)serialization of `Infraction` instances.""" diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index 64329ebe..cbadcf2b 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -52,7 +52,28 @@ class FilterListViewSet(ModelViewSet): ... }, ... ... ... ], - ... "default_settings": 1 + ... "settings": { + ... "dm_content": None, + ... "infraction_type": None, + ... "infraction_reason": "", + ... "infraction_duration": None, + ... "ping_type": [ + ... "onduty" + ... ], + ... "filter_dm": True, + ... "dm_ping_type": [ + ... "onduty" + ... ], + ... "delete_messages": True, + ... "bypass_roles": [ + ... 267630620367257601 + ... ], + ... "enabled": False, + ... "disallowed_channels": [], + ... "disallowed_categories": [], + ... "allowed_channels": [], + ... "allowed_categories": [] + ... } ... }, ... ... ... ] @@ -65,6 +86,7 @@ class FilterListViewSet(ModelViewSet): Returns a specific FilterList item from the database. #### Response format + >>> ... { ... "id": 1, ... "name": "guild_invite", @@ -95,9 +117,30 @@ class FilterListViewSet(ModelViewSet): ... } ... ... }, - ... ... + ... ... ], - ... "default_settings": 1 + ... "settings": { + ... "dm_content": None, + ... "infraction_type": None, + ... "infraction_reason": "", + ... "infraction_duration": None, + ... "ping_type": [ + ... "onduty" + ... ], + ... "filter_dm": True, + ... "dm_ping_type": [ + ... "onduty" + ... ], + ... "delete_messages": True, + ... "bypass_roles": [ + ... 267630620367257601 + ... ], + ... "enabled": False, + ... "disallowed_channels": [], + ... "disallowed_categories": [], + ... "allowed_channels": [], + ... "allowed_categories": [] + ... } ... } #### Status codes -- cgit v1.2.3 From 3e8f164525bdd3a728bb7383da237feb9aacb44e Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Thu, 9 Dec 2021 20:53:49 +0100 Subject: Adjust FilterList Schema to group settings into subcategories - This commit patches the FilterList serializer's schema, and puts the settings into the relevant subcategories. --- pydis_site/apps/api/serializers.py | 9 ++- pydis_site/apps/api/viewsets/bot/filters.py | 93 ++++++++++++++--------------- 2 files changed, 54 insertions(+), 48 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 267cf761..89005a9b 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -269,7 +269,14 @@ class FilterListSerializer(ModelSerializer): That does not affect how the Serializer works in general. """ ret = super().to_representation(instance) - ret["settings"] = {name: getattr(instance, name) for name in SETTINGS_FIELDS} + schema_base = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} + schema_settings = { + "infraction": + {name: getattr(instance, name) for name in INFRACTION_FIELDS}} \ + | { + "channel_scope": + {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}} + ret["settings"] = schema_base | schema_settings return ret diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index cbadcf2b..2b587696 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -52,31 +52,29 @@ class FilterListViewSet(ModelViewSet): ... }, ... ... ... ], - ... "settings": { - ... "dm_content": None, - ... "infraction_type": None, - ... "infraction_reason": "", - ... "infraction_duration": None, - ... "ping_type": [ - ... "onduty" - ... ], - ... "filter_dm": True, - ... "dm_ping_type": [ - ... "onduty" - ... ], - ... "delete_messages": True, - ... "bypass_roles": [ - ... 267630620367257601 - ... ], - ... "enabled": False, - ... "disallowed_channels": [], - ... "disallowed_categories": [], - ... "allowed_channels": [], - ... "allowed_categories": [] - ... } - ... }, - ... ... - ... ] + ... "settings": { + ... "ping_type": [ + ... "onduty" + ... ], + ... "dm_ping_type": [ + ... "onduty" + ... ], + ... "bypass_roles": [ + ... 267630620367257601 + ... ], + ... "filter_dm": True, + ... "infraction": { + ... "infraction_type": None, + ... "infraction_reason": "", + ... "infraction_duration": None, + ... } + ... "channel_scope": { + ... "disallowed_channels": [], + ... "disallowed_categories": [], + ... "allowed_channels": [], + ... "allowed_categories": [] + ... } + ... } #### Status codes - 200: returned on success @@ -120,28 +118,29 @@ class FilterListViewSet(ModelViewSet): ... ... ], ... "settings": { - ... "dm_content": None, - ... "infraction_type": None, - ... "infraction_reason": "", - ... "infraction_duration": None, - ... "ping_type": [ - ... "onduty" - ... ], - ... "filter_dm": True, - ... "dm_ping_type": [ - ... "onduty" - ... ], - ... "delete_messages": True, - ... "bypass_roles": [ - ... 267630620367257601 - ... ], - ... "enabled": False, - ... "disallowed_channels": [], - ... "disallowed_categories": [], - ... "allowed_channels": [], - ... "allowed_categories": [] - ... } - ... } + ... "ping_type": [ + ... "onduty" + ... ], + ... "dm_ping_type": [ + ... "onduty" + ... ], + ... "bypass_roles": [ + ... 267630620367257601 + ... ], + ... "filter_dm": True, + ... "infraction": { + ... "infraction_type": None, + ... "infraction_reason": "", + ... "infraction_duration": None, + ... } + ... "channel_scope": { + ... "disallowed_channels": [], + ... "disallowed_categories": [], + ... "allowed_channels": [], + ... "allowed_categories": [] + ... } + ... } + ... } #### Status codes - 200: returned on success -- cgit v1.2.3 From a24cac8d43893f792d4fa495cf2a9ce65f69051c Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Fri, 10 Dec 2021 22:19:09 +0100 Subject: Patch Filter and FilterList Serializer validation logic and representation - This commit patches an error with the FilterListSerializer validation logic, so that it won't raise an error when an optional field is not present. - It also adds the `enabled` and `delete_messages` fields, to the FilterSerializer's representation - Furthermore the commit introduces minor bug patches, regarding DRF Serializer Fields. --- pydis_site/apps/api/serializers.py | 73 +++++++++++++++++++---------- pydis_site/apps/api/viewsets/bot/filters.py | 12 +++++ 2 files changed, 61 insertions(+), 24 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 89005a9b..cb8313ac 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -134,8 +134,16 @@ REQUIRED_FOR_FILTER_LIST_SETTINGS = ( ) # Required fields for custom JSON representation purposes -BASE_FIELDS = ('id', 'content', 'description', 'additional_field') -BASE_SETTINGS_FIELDS = ("ping_type", "dm_ping_type", "bypass_roles", "filter_dm") +BASE_FILTER_FIELDS = ('id', 'content', 'description', 'additional_field') +BASE_FILTERLIST_FIELDS = ('id', 'name', 'list_type') +BASE_SETTINGS_FIELDS = ( + "ping_type", + "dm_ping_type", + "bypass_roles", + "filter_dm", + "enabled", + "delete_messages" +) INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration") CHANNEL_SCOPE_FIELDS = ( "allowed_channels", @@ -181,7 +189,7 @@ class FilterSerializer(ModelSerializer): model = Filter fields = ( 'id', 'content', 'description', 'additional_field', 'filter_list' - ) + ) + SETTINGS_FIELDS extra_kwargs = { field: {'required': False, 'allow_null': True} for field in SETTINGS_FIELDS } | { @@ -199,16 +207,18 @@ class FilterSerializer(ModelSerializer): That does not affect how the Serializer works in general. """ - item = Filter.objects.get(id=instance.id) schema_settings = { "settings": - {name: getattr(item, name) for name in BASE_SETTINGS_FIELDS} - | {"infraction": {name: getattr(item, name) for name in INFRACTION_FIELDS}} - | {"channel_scope": {name: getattr(item, name) for name in CHANNEL_SCOPE_FIELDS}} + {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} + | {"infraction": {name: getattr(instance, name) for name in INFRACTION_FIELDS}} + | { + "channel_scope": + {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS} + } } - schema_base = {name: getattr(item, name) for name in BASE_FIELDS} | \ - {"filter_list": item.filter_list.id} + schema_base = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS} | \ + {"filter_list": instance.filter_list.id} return schema_base | schema_settings @@ -221,18 +231,25 @@ class FilterListSerializer(ModelSerializer): def validate(self, data: dict) -> dict: """Perform infraction data + allow and disallowed lists validation.""" if ( - data['infraction_reason'] or data['infraction_duration'] - ) and not data['infraction_type']: + data.get('infraction_reason') or data.get('infraction_duration') + ) and not data.get('infraction_type'): raise ValidationError("Infraction type is required with infraction duration or reason") - channels_collection = data['allowed_channels'] + data['disallowed_channels'] - categories_collection = data['allowed_categories'] + data['disallowed_categories'] - - if len(channels_collection) != len(set(channels_collection)): - raise ValidationError("Allowed and disallowed channels lists contain duplicates.") + if ( + data.get('allowed_channels') is not None + and data.get('disallowed_channels') is not None + ): + channels_collection = data['allowed_channels'] + data['disallowed_channels'] + if len(channels_collection) != len(set(channels_collection)): + raise ValidationError("Allowed and disallowed channels lists contain duplicates.") - if len(categories_collection) != len(set(categories_collection)): - raise ValidationError("Allowed and disallowed categories lists contain duplicates.") + if ( + data.get('allowed_categories') is not None + and data.get('disallowed_categories') is not None + ): + categories_collection = data['allowed_categories'] + data['disallowed_categories'] + if len(categories_collection) != len(set(categories_collection)): + raise ValidationError("Allowed and disallowed categories lists contain duplicates.") return data @@ -240,7 +257,7 @@ class FilterListSerializer(ModelSerializer): """Metadata defined for the Django REST Framework.""" model = FilterList - fields = ('id', 'name', 'list_type', 'filters') + fields = ('id', 'name', 'list_type', 'filters') + SETTINGS_FIELDS extra_kwargs = { field: {'required': False, 'allow_null': True} for field in ALWAYS_OPTIONAL_SETTINGS } | { @@ -268,16 +285,24 @@ class FilterListSerializer(ModelSerializer): That does not affect how the Serializer works in general. """ - ret = super().to_representation(instance) - schema_base = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} - schema_settings = { + # Fetches the relating filters + filters = [ + FilterSerializer(many=False).to_representation( + instance=item + ) for item in Filter.objects.filter( + filter_list=instance.id + ) + ] + schema_base = {name: getattr(instance, name) for name in BASE_FILTERLIST_FIELDS} \ + | {"filters": filters} + schema_settings_base = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} + schema_settings_categories = { "infraction": {name: getattr(instance, name) for name in INFRACTION_FIELDS}} \ | { "channel_scope": {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}} - ret["settings"] = schema_base | schema_settings - return ret + return schema_base | {"settings": schema_settings_base | schema_settings_categories} class InfractionSerializer(ModelSerializer): diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index 2b587696..e8f3e3d9 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -36,6 +36,8 @@ class FilterListViewSet(ModelViewSet): ... "dm_ping_type": None ... "bypass_roles": None ... "filter_dm": None, + ... "enabled": False + ... "delete_messages": True ... "infraction": { ... "infraction_type": None, ... "infraction_reason": "", @@ -63,6 +65,8 @@ class FilterListViewSet(ModelViewSet): ... 267630620367257601 ... ], ... "filter_dm": True, + ... "enabled": False + ... "delete_messages": True ... "infraction": { ... "infraction_type": None, ... "infraction_reason": "", @@ -101,6 +105,8 @@ class FilterListViewSet(ModelViewSet): ... "dm_ping_type": None ... "bypass_roles": None ... "filter_dm": None, + ... "enabled": False + ... "delete_messages": True ... "infraction": { ... "infraction_type": None, ... "infraction_reason": "", @@ -128,6 +134,8 @@ class FilterListViewSet(ModelViewSet): ... 267630620367257601 ... ], ... "filter_dm": True, + ... "enabled": False + ... "delete_messages": True ... "infraction": { ... "infraction_type": None, ... "infraction_reason": "", @@ -179,6 +187,8 @@ class FilterViewSet(ModelViewSet): ... "dm_ping_type": None ... "bypass_roles": None ... "filter_dm": None, + ... "enabled": False + ... "delete_messages": True ... "infraction": { ... "infraction_type": None, ... "infraction_reason": "", @@ -214,6 +224,8 @@ class FilterViewSet(ModelViewSet): ... "dm_ping_type": None ... "bypass_roles": None ... "filter_dm": None, + ... "enabled": False + ... "delete_messages": True ... "infraction": { ... "infraction_type": None, ... "infraction_reason": "", -- cgit v1.2.3 From 4c93b1b9b75cce4e45bdbdae608f4497372c2b56 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Wed, 15 Dec 2021 19:25:55 +0100 Subject: Prepare FilterList and Filter models, serializers for the new filter schema - Rename channel scope fields: - "allowed" -> "disabled" eg.: "allowed_channels" -> "disabled_channels" - Rename FilterLists` names: filter_token -> tokens domain_name -> domains guild_invite -> invites file_format -> formats - Patch the docs and validators accordingly. --- ..._filter_and_filterlist_for_new_filter_schema.py | 80 ++++++++++++++++++ pydis_site/apps/api/models/bot/filters.py | 21 +++-- pydis_site/apps/api/serializers.py | 90 +++++++++++---------- pydis_site/apps/api/viewsets/bot/filters.py | 94 ++++++++++++---------- 4 files changed, 189 insertions(+), 96 deletions(-) create mode 100644 pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py new file mode 100644 index 00000000..30537e3d --- /dev/null +++ b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py @@ -0,0 +1,80 @@ +# Generated by Django 3.0.14 on 2021-12-11 23:14 +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + FilterList = apps.get_model("api", "FilterList") + change_map = { + "filter_token": "tokens", + "domain_name": "domains", + "guild_invite": "invites", + "file_format": "formats" + } + for filter_list in FilterList.objects.all(): + if change_map.get(filter_list.name): + filter_list.name = change_map.get(filter_list.name) + filter_list.save() + + +def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + FilterList = apps.get_model("api", "FilterList") + change_map = { + "tokens": "filter_token", + "domains": "domain_name", + "invites": "guild_invite", + "formats": "file_format" + } + for filter_list in FilterList.objects.all(): + if change_map.get(filter_list.name): + filter_list.name = change_map.get(filter_list.name) + filter_list.save() + + +class Migration(migrations.Migration): + dependencies = [ + ('api', '0074_merge_20211017_0822'), + ] + + operations = [ + migrations.RenameField( + model_name='filter', + old_name='allowed_categories', + new_name='disabled_categories', + ), + migrations.RenameField( + model_name='filter', + old_name='allowed_channels', + new_name='disabled_channels', + ), + migrations.RenameField( + model_name='filter', + old_name='disallowed_channels', + new_name='enabled_channels', + ), + migrations.RenameField( + model_name='filterlist', + old_name='allowed_categories', + new_name='disabled_categories', + ), + migrations.RenameField( + model_name='filterlist', + old_name='allowed_channels', + new_name='disabled_channels', + ), + migrations.RenameField( + model_name='filterlist', + old_name='disallowed_channels', + new_name='enabled_channels', + ), + migrations.RemoveField( + model_name='filterlist', + name='disallowed_categories', + ), + migrations.RemoveField( + model_name='filter', + name='disallowed_categories', + ), + migrations.RunPython(migrate_filterlist, unmigrate_filterlist) + ] diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 3a1f3c6a..ae877685 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -99,14 +99,12 @@ class FilterList(FilterSettingsMixin): # Where a filter should apply. # # The resolution is done in the following order: - # - disallowed channels - # - disallowed categories - # - allowed categories - # - allowed channels - disallowed_channels = ArrayField(models.IntegerField()) - disallowed_categories = ArrayField(models.IntegerField()) - allowed_channels = ArrayField(models.IntegerField()) - allowed_categories = ArrayField(models.IntegerField()) + # - enabled_channels + # - disabled_categories + # - disabled_channels + enabled_channels = ArrayField(models.IntegerField()) + disabled_channels = ArrayField(models.IntegerField()) + disabled_categories = ArrayField(models.IntegerField()) class Meta: """Constrain name and list_type unique.""" @@ -157,10 +155,9 @@ class Filter(FilterSettingsMixin): ) # Check FilterList model for information about these properties. - disallowed_channels = ArrayField(models.IntegerField(), null=True) - disallowed_categories = ArrayField(models.IntegerField(), null=True) - allowed_channels = ArrayField(models.IntegerField(), null=True) - allowed_categories = ArrayField(models.IntegerField(), null=True) + enabled_channels = ArrayField(models.IntegerField(), null=True) + disabled_channels = ArrayField(models.IntegerField(), null=True) + disabled_categories = ArrayField(models.IntegerField(), null=True) def __str__(self) -> str: return f"Filter {self.content!r}" diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index cb8313ac..784f8160 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -127,18 +127,15 @@ REQUIRED_FOR_FILTER_LIST_SETTINGS = ( 'delete_messages', 'bypass_roles', 'enabled', - 'disallowed_channels', - 'disallowed_categories', - 'allowed_channels', - 'allowed_categories', + 'enabled_channels', + 'disabled_channels', + 'disabled_categories', ) # Required fields for custom JSON representation purposes BASE_FILTER_FIELDS = ('id', 'content', 'description', 'additional_field') BASE_FILTERLIST_FIELDS = ('id', 'name', 'list_type') BASE_SETTINGS_FIELDS = ( - "ping_type", - "dm_ping_type", "bypass_roles", "filter_dm", "enabled", @@ -146,11 +143,11 @@ BASE_SETTINGS_FIELDS = ( ) INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration") CHANNEL_SCOPE_FIELDS = ( - "allowed_channels", - "allowed_categories", - "disallowed_channels", - "disallowed_categories" + "disabled_channels", + "disabled_categories", + "enabled_channels", ) +MENTIONS_FIELDS = ("ping_type", "dm_ping_type") SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS @@ -166,20 +163,17 @@ class FilterSerializer(ModelSerializer): raise ValidationError("Infraction type is required with infraction duration or reason") if ( - data.get('allowed_channels') is not None - and data.get('disallowed_channels') is not None + data.get('disabled_channels') is not None + and data.get('enabled_channels') is not None ): - channels_collection = data['allowed_channels'] + data['disallowed_channels'] + channels_collection = data['disabled_channels'] + data['enabled_channels'] if len(channels_collection) != len(set(channels_collection)): - raise ValidationError("Allowed and disallowed channels lists contain duplicates.") + raise ValidationError("Enabled and Disabled channels lists contain duplicates.") - if ( - data.get('allowed_categories') is not None - and data.get('disallowed_categories') is not None - ): - categories_collection = data['allowed_categories'] + data['disallowed_categories'] + if data.get('disabled_categories') is not None: + categories_collection = data['disabled_categories'] if len(categories_collection) != len(set(categories_collection)): - raise ValidationError("Allowed and disallowed categories lists contain duplicates.") + raise ValidationError("Disabled categories lists contain duplicates.") return data @@ -194,18 +188,20 @@ class FilterSerializer(ModelSerializer): field: {'required': False, 'allow_null': True} for field in SETTINGS_FIELDS } | { 'infraction_reason': {'allow_blank': True, 'allow_null': True, 'required': False}, - 'disallowed_channels': {'allow_empty': True, 'allow_null': True, 'required': False}, - 'disallowed_categories': {'allow_empty': True, 'allow_null': True, 'required': False}, - 'allowed_channels': {'allow_empty': True, 'allow_null': True, 'required': False}, - 'allowed_categories': {'allow_empty': True, 'allow_null': True, 'required': False}, + 'enabled_channels': {'allow_empty': True, 'allow_null': True, 'required': False}, + 'disabled_channels': {'allow_empty': True, 'allow_null': True, 'required': False}, + 'disabled_categories': {'allow_empty': True, 'allow_null': True, 'required': False}, } def to_representation(self, instance: Filter) -> dict: """ - Provides a custom JSON representation to the Filter Serializers. - That does not affect how the Serializer works in general. + This representation restructures how the Filter is represented. + It groups the Infraction, Channel and Mention related fields into their own separated group. + + Furthermore, it puts the fields that meant to represent Filter settings, + into a sub-field called `settings`. """ schema_settings = { "settings": @@ -214,6 +210,11 @@ class FilterSerializer(ModelSerializer): | { "channel_scope": {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS} + } | { + "mentions": + { + schema_field_name: getattr(instance, schema_field_name) + for schema_field_name in MENTIONS_FIELDS} } } @@ -236,20 +237,17 @@ class FilterListSerializer(ModelSerializer): raise ValidationError("Infraction type is required with infraction duration or reason") if ( - data.get('allowed_channels') is not None - and data.get('disallowed_channels') is not None + data.get('disabled_channels') is not None + and data.get('enabled_channels') is not None ): - channels_collection = data['allowed_channels'] + data['disallowed_channels'] + channels_collection = data['disabled_channels'] + data['enabled_channels'] if len(channels_collection) != len(set(channels_collection)): - raise ValidationError("Allowed and disallowed channels lists contain duplicates.") + raise ValidationError("Enabled and Disabled channels lists contain duplicates.") - if ( - data.get('allowed_categories') is not None - and data.get('disallowed_categories') is not None - ): - categories_collection = data['allowed_categories'] + data['disallowed_categories'] + if data.get('disabled_categories') is not None: + categories_collection = data['disabled_categories'] if len(categories_collection) != len(set(categories_collection)): - raise ValidationError("Allowed and disallowed categories lists contain duplicates.") + raise ValidationError("Disabled categories lists contain duplicates.") return data @@ -262,10 +260,9 @@ class FilterListSerializer(ModelSerializer): field: {'required': False, 'allow_null': True} for field in ALWAYS_OPTIONAL_SETTINGS } | { 'infraction_reason': {'allow_blank': True, 'allow_null': True, 'required': False}, - 'disallowed_channels': {'allow_empty': True}, - 'disallowed_categories': {'allow_empty': True}, - 'allowed_channels': {'allow_empty': True}, - 'allowed_categories': {'allow_empty': True}, + 'enabled_channels': {'allow_empty': True}, + 'disabled_channels': {'allow_empty': True}, + 'disabled_categories': {'allow_empty': True}, } # Ensure that we can only have one filter list with the same name and field @@ -283,7 +280,11 @@ class FilterListSerializer(ModelSerializer): """ Provides a custom JSON representation to the FilterList Serializers. - That does not affect how the Serializer works in general. + This representation restructures how the Filter is represented. + It groups the Infraction, Channel and Mention related fields into their own separated group. + + Furthermore, it puts the fields that meant to represent FilterList settings, + into a sub-field called `settings`. """ # Fetches the relating filters filters = [ @@ -301,7 +302,12 @@ class FilterListSerializer(ModelSerializer): {name: getattr(instance, name) for name in INFRACTION_FIELDS}} \ | { "channel_scope": - {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}} + {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}} | { + "mentions": { + schema_field_name: getattr(instance, schema_field_name) + for schema_field_name in MENTIONS_FIELDS + } + } return schema_base | {"settings": schema_settings_base | schema_settings_categories} diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index e8f3e3d9..20af079d 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -32,8 +32,6 @@ class FilterListViewSet(ModelViewSet): ... "description": "Python Discord", ... "additional_field": None, ... "settings": { - ... "ping_type": None, - ... "dm_ping_type": None ... "bypass_roles": None ... "filter_dm": None, ... "enabled": False @@ -44,11 +42,14 @@ class FilterListViewSet(ModelViewSet): ... "infraction_duration": None ... }, ... "channel_scope": { - ... "allowed_channels": None, - ... "allowed_categories": None, - ... "disallowed_channels": None, - ... "disallowed_categories": None + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None ... } + ... "mentions": { + ... "ping_type": None + ... "dm_ping_type": None + ... } ... } ... ... }, @@ -72,13 +73,18 @@ class FilterListViewSet(ModelViewSet): ... "infraction_reason": "", ... "infraction_duration": None, ... } - ... "channel_scope": { - ... "disallowed_channels": [], - ... "disallowed_categories": [], - ... "allowed_channels": [], - ... "allowed_categories": [] - ... } - ... } + ... "channel_scope": { + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None + ... } + ... "mentions": { + ... "ping_type": None + ... "dm_ping_type": None + ... } + ... }, + ... ... + ... ] #### Status codes - 200: returned on success @@ -88,8 +94,7 @@ class FilterListViewSet(ModelViewSet): Returns a specific FilterList item from the database. #### Response format - >>> - ... { + >>> { ... "id": 1, ... "name": "guild_invite", ... "list_type": 1, @@ -101,8 +106,6 @@ class FilterListViewSet(ModelViewSet): ... "description": "Python Discord", ... "additional_field": None, ... "settings": { - ... "ping_type": None, - ... "dm_ping_type": None ... "bypass_roles": None ... "filter_dm": None, ... "enabled": False @@ -113,15 +116,18 @@ class FilterListViewSet(ModelViewSet): ... "infraction_duration": None ... }, ... "channel_scope": { - ... "allowed_channels": None, - ... "allowed_categories": None, - ... "disallowed_channels": None, - ... "disallowed_categories": None + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None ... } + ... "mentions": { + ... "ping_type": None + ... "dm_ping_type": None + ... } ... } ... ... }, - ... + ... ... ... ], ... "settings": { ... "ping_type": [ @@ -141,14 +147,16 @@ class FilterListViewSet(ModelViewSet): ... "infraction_reason": "", ... "infraction_duration": None, ... } - ... "channel_scope": { - ... "disallowed_channels": [], - ... "disallowed_categories": [], - ... "allowed_channels": [], - ... "allowed_categories": [] - ... } - ... } - ... } + ... "channel_scope": { + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None + ... } + ... "mentions": { + ... "ping_type": None + ... "dm_ping_type": None + ... } + ... } #### Status codes - 200: returned on success @@ -183,8 +191,6 @@ class FilterViewSet(ModelViewSet): ... "description": "Python Discord", ... "additional_field": None, ... "settings": { - ... "ping_type": None, - ... "dm_ping_type": None ... "bypass_roles": None ... "filter_dm": None, ... "enabled": False @@ -195,11 +201,14 @@ class FilterViewSet(ModelViewSet): ... "infraction_duration": None ... }, ... "channel_scope": { - ... "allowed_channels": None, - ... "allowed_categories": None, - ... "disallowed_channels": None, - ... "disallowed_categories": None + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None ... } + ... "mentions": { + ... "ping_type": None, + ... "dm_ping_type": None + ... } ... } ... }, ... ... @@ -220,8 +229,6 @@ class FilterViewSet(ModelViewSet): ... "description": "Python Discord", ... "additional_field": None, ... "settings": { - ... "ping_type": None, - ... "dm_ping_type": None ... "bypass_roles": None ... "filter_dm": None, ... "enabled": False @@ -232,11 +239,14 @@ class FilterViewSet(ModelViewSet): ... "infraction_duration": None ... }, ... "channel_scope": { - ... "allowed_channels": None, - ... "allowed_categories": None, - ... "disallowed_channels": None, - ... "disallowed_categories": None + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None, ... } + ... "mentions": { + ... "ping_type": None + ... "dm_ping_type": None + ... } ... } ... } -- cgit v1.2.3 From af3980fe65b997287ceaf68e53ce3ab7bf4607e5 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Wed, 22 Dec 2021 18:18:22 +0100 Subject: Patch Filter/FilterList's default values and add new fields - Patch default values, so that further implementations can be performed on the bot side - Add three new fields: "send_alert", and in settings under the "server_message" field: "send_message_text", and "server_message_embed" fields. - Patch documentation, and validators accordingly. - Perform further patches, and minor corrections. --- .../apps/api/migrations/0070_new_filter_schema.py | 28 ++++-- ..._filter_and_filterlist_for_new_filter_schema.py | 17 +++- .../api/migrations/0078_merge_20211218_2200.py | 14 +++ .../0079_add_server_message_and_alert_fields.py | 69 ++++++++++++++ pydis_site/apps/api/models/bot/filters.py | 44 ++++++++- pydis_site/apps/api/serializers.py | 19 +++- pydis_site/apps/api/viewsets/bot/filters.py | 104 ++++++++++++--------- 7 files changed, 234 insertions(+), 61 deletions(-) create mode 100644 pydis_site/apps/api/migrations/0078_merge_20211218_2200.py create mode 100644 pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index 8716cbad..f56c29f8 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -1,4 +1,5 @@ # Modified migration file to migrate existing filters to the new one +from datetime import timedelta import django.contrib.postgres.fields from django.apps.registry import Apps @@ -18,20 +19,27 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: for name, type_ in OLD_LIST_NAMES: objects = filter_list_old.objects.filter(type=name) + if name == "DOMAIN_NAME": + dm_content = "Your URL has been removed because it matched a blacklisted domain: {match}" + elif name == "GUILD_INVITE": + dm_content = "Per Rule 6, your invite link has been removed. " \ + "Our server rules can be found here: https://pythondiscord.com/pages/rules" + else: + dm_content = "" list_ = filter_list.objects.create( name=name.lower(), list_type=1 if type_ == "ALLOW" else 0, - ping_type=["onduty"], + ping_type=(["onduty"] if name != "FILE_FORMAT" else []), filter_dm=True, - dm_ping_type=["onduty"], - delete_messages=True, - bypass_roles=[267630620367257601], - enabled=False, - dm_content=None, - infraction_type=None, + dm_ping_type=[], + delete_messages=(True if name != "FILTER_TOKEN" else False), + bypass_roles=["staff"], + enabled=True, + dm_content=dm_content, + infraction_type="", infraction_reason="", - infraction_duration=None, + infraction_duration=timedelta(seconds=0), disallowed_channels=[], disallowed_categories=[], allowed_channels=[], @@ -84,7 +92,7 @@ class Migration(migrations.Migration): ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)), - ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None, null=True)), + ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field], null=True)), ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)), ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), @@ -106,7 +114,7 @@ class Migration(migrations.Migration): ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')), ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')), - ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(), help_text='Roles and users who can bypass this filter.', size=None)), + ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field])), ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')), ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py index 30537e3d..cc524fcb 100644 --- a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py @@ -10,12 +10,26 @@ def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> N "filter_token": "tokens", "domain_name": "domains", "guild_invite": "invites", - "file_format": "formats" + "file_format": "extensions" } for filter_list in FilterList.objects.all(): if change_map.get(filter_list.name): filter_list.name = change_map.get(filter_list.name) filter_list.save() + redirects = FilterList( + name="redirects", + ping_type=[], + dm_ping_type=[], + enabled_channels=[], + disabled_channels=[], + disabled_categories=[], + list_type=0, + filter_dm=True, + delete_messages=False, + bypass_roles=[0], + enabled=True + ) + redirects.save() def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: @@ -30,6 +44,7 @@ def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> if change_map.get(filter_list.name): filter_list.name = change_map.get(filter_list.name) filter_list.save() + FilterList.objects.filter(name="redirects").delete() class Migration(migrations.Migration): diff --git a/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py b/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py new file mode 100644 index 00000000..7fe559f5 --- /dev/null +++ b/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.14 on 2021-12-18 22:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0075_prepare_filter_and_filterlist_for_new_filter_schema'), + ('api', '0077_use_generic_jsonfield'), + ] + + operations = [ + ] diff --git a/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py new file mode 100644 index 00000000..f9803bd3 --- /dev/null +++ b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py @@ -0,0 +1,69 @@ +# Generated by Django 3.1.14 on 2021-12-19 23:05 +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + FilterList = apps.get_model("api", "FilterList") + change_map = { + "tokens": True, + "domains": True, + "invites": True, + "extensions": False, + "redirects": False + } + for filter_list in FilterList.objects.all(): + filter_list.send_alert = change_map.get(filter_list.name) + filter_list.server_message_text = "" + filter_list.server_message_embed = "" + filter_list.save() + + +def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + FilterList = apps.get_model("api", "FilterList") + for filter_list in FilterList.objects.all(): + filter_list.send_alert = True + filter_list.server_message_text = None + filter_list.server_message_embed = None + filter_list.save() + + +class Migration(migrations.Migration): + dependencies = [ + ('api', '0078_merge_20211218_2200'), + ] + + operations = [ + migrations.AddField( + model_name='filter', + name='send_alert', + field=models.BooleanField(help_text='Whether alert should be sent.', null=True), + ), + migrations.AddField( + model_name='filter', + name='server_message_embed', + field=models.CharField(help_text='The content of the server message embed', max_length=100, null=True), + ), + migrations.AddField( + model_name='filter', + name='server_message_text', + field=models.CharField(help_text='The message to send on the server', max_length=100, null=True), + ), + migrations.AddField( + model_name='filterlist', + name='send_alert', + field=models.BooleanField(default=True, help_text='Whether alert should be sent.'), + ), + migrations.AddField( + model_name='filterlist', + name='server_message_embed', + field=models.CharField(help_text='The content of the server message embed', max_length=100, null=True), + ), + migrations.AddField( + model_name='filterlist', + name='server_message_text', + field=models.CharField(help_text='The message to send on the server', max_length=100, null=True), + ), + migrations.RunPython(migrate_filterlist, unmigrate_filterlist) + ] diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index ae877685..92251ee4 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -18,6 +18,7 @@ class FilterListType(models.IntegerChoices): # Valid special values in ping related fields VALID_PINGS = ("everyone", "here", "moderators", "onduty", "admins") +VALID_BYPASS_ROLES = ("staff",) def validate_ping_field(value_list: List[str]) -> None: @@ -33,6 +34,14 @@ def validate_ping_field(value_list: List[str]) -> None: raise ValidationError(f"{value!r} isn't a valid ping type.") +def validate_bypass_roles_field(value_list: List[str]) -> None: + """Validate that the vclues are either a special value or a Role ID.""" + for value in value_list: + if value.isnumeric() or value in VALID_BYPASS_ROLES: + continue + raise ValidationError(f"{value!r} isn't a valid (bypass) role.") + + class FilterSettingsMixin(models.Model): """Mixin for common settings of a filters and filter lists.""" @@ -88,14 +97,30 @@ class FilterList(FilterSettingsMixin): null=False ) bypass_roles = ArrayField( - models.BigIntegerField(), + models.CharField(max_length=100), help_text="Roles and users who can bypass this filter.", + validators=(validate_bypass_roles_field,), null=False ) enabled = models.BooleanField( help_text="Whether this filter is currently enabled.", null=False ) + send_alert = models.BooleanField( + help_text="Whether alert should be sent.", + null=False, + default=True + ) + server_message_text = models.CharField( + max_length=100, + help_text="The message to send on the server", + null=True + ) + server_message_embed = models.CharField( + max_length=100, + help_text="The content of the server message embed", + null=True + ) # Where a filter should apply. # # The resolution is done in the following order: @@ -145,14 +170,29 @@ class Filter(FilterSettingsMixin): null=True ) bypass_roles = ArrayField( - models.BigIntegerField(), + models.CharField(max_length=100), help_text="Roles and users who can bypass this filter.", + validators=(validate_bypass_roles_field,), null=True ) enabled = models.BooleanField( help_text="Whether this filter is currently enabled.", null=True ) + send_alert = models.BooleanField( + help_text="Whether alert should be sent.", + null=True + ) + server_message_text = models.CharField( + max_length=100, + help_text="The message to send on the server", + null=True + ) + server_message_embed = models.CharField( + max_length=100, + help_text="The content of the server message embed", + null=True + ) # Check FilterList model for information about these properties. enabled_channels = ArrayField(models.IntegerField(), null=True) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 784f8160..30af9512 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -139,7 +139,8 @@ BASE_SETTINGS_FIELDS = ( "bypass_roles", "filter_dm", "enabled", - "delete_messages" + "delete_messages", + "send_alert" ) INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration") CHANNEL_SCOPE_FIELDS = ( @@ -147,6 +148,7 @@ CHANNEL_SCOPE_FIELDS = ( "disabled_categories", "enabled_channels", ) +SERVER_MESSAGE_FIELDS = ("server_message_text", "server_message_embed") MENTIONS_FIELDS = ("ping_type", "dm_ping_type") SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS @@ -214,10 +216,16 @@ class FilterSerializer(ModelSerializer): "mentions": { schema_field_name: getattr(instance, schema_field_name) - for schema_field_name in MENTIONS_FIELDS} + for schema_field_name in MENTIONS_FIELDS + } } + } | { + "server_message": + { + schema_field_name: getattr(instance, schema_field_name) + for schema_field_name in SERVER_MESSAGE_FIELDS + } } - schema_base = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS} | \ {"filter_list": instance.filter_list.id} @@ -307,6 +315,11 @@ class FilterListSerializer(ModelSerializer): schema_field_name: getattr(instance, schema_field_name) for schema_field_name in MENTIONS_FIELDS } + } | { + "server_message": { + schema_field_name: getattr(instance, schema_field_name) + for schema_field_name in SERVER_MESSAGE_FIELDS + } } return schema_base | {"settings": schema_settings_base | schema_settings_categories} diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index 20af079d..e52cd4e5 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -22,20 +22,21 @@ class FilterListViewSet(ModelViewSet): >>> [ ... { ... "id": 1, - ... "name": "guild_invite", + ... "name": "invites", ... "list_type": 1, ... "filters": [ ... { ... "id": 1, - ... "filter_list": 1 ... "content": "267624335836053506", ... "description": "Python Discord", ... "additional_field": None, + ... "filter_list": 1 ... "settings": { ... "bypass_roles": None ... "filter_dm": None, - ... "enabled": False - ... "delete_messages": True + ... "enabled": None + ... "send_alert": True, + ... "delete_messages": None ... "infraction": { ... "infraction_type": None, ... "infraction_reason": "", @@ -50,37 +51,42 @@ class FilterListViewSet(ModelViewSet): ... "ping_type": None ... "dm_ping_type": None ... } + ... "server_message": { + ... "server_message_text": None, + ... "server_message_embed": None + ... } ... } ... ... }, ... ... ... ], ... "settings": { - ... "ping_type": [ - ... "onduty" - ... ], - ... "dm_ping_type": [ - ... "onduty" - ... ], ... "bypass_roles": [ - ... 267630620367257601 + ... "staff" ... ], ... "filter_dm": True, - ... "enabled": False - ... "delete_messages": True + ... "enabled": True + ... "delete_messages": True, + ... "send_alert": True ... "infraction": { - ... "infraction_type": None, + ... "infraction_type": "", ... "infraction_reason": "", - ... "infraction_duration": None, + ... "infraction_duration": "0.0", ... } ... "channel_scope": { - ... "disabled_channels": None, - ... "disabled_categories": None, - ... "enabled_channels": None - ... } + ... "disabled_channels": [], + ... "disabled_categories": [], + ... "enabled_channels": [] + ... } ... "mentions": { - ... "ping_type": None - ... "dm_ping_type": None + ... "ping_type": [ + ... "onduty" + ... ] + ... "dm_ping_type": [] + ... } + ... "server_message": { + ... "server_message_text": "", + ... "server_message_embed": "" ... } ... }, ... ... @@ -96,7 +102,7 @@ class FilterListViewSet(ModelViewSet): #### Response format >>> { ... "id": 1, - ... "name": "guild_invite", + ... "name": "invites", ... "list_type": 1, ... "filters": [ ... { @@ -108,8 +114,9 @@ class FilterListViewSet(ModelViewSet): ... "settings": { ... "bypass_roles": None ... "filter_dm": None, - ... "enabled": False - ... "delete_messages": True + ... "enabled": None + ... "delete_messages": None, + ... "send_alert": None ... "infraction": { ... "infraction_type": None, ... "infraction_reason": "", @@ -124,37 +131,42 @@ class FilterListViewSet(ModelViewSet): ... "ping_type": None ... "dm_ping_type": None ... } + ... "server_message": { + ... "server_message_text": None, + ... "server_message_embed": None + ... } ... } ... ... }, ... ... ... ], ... "settings": { - ... "ping_type": [ - ... "onduty" - ... ], - ... "dm_ping_type": [ - ... "onduty" - ... ], ... "bypass_roles": [ - ... 267630620367257601 + ... "staff" ... ], ... "filter_dm": True, - ... "enabled": False + ... "enabled": True ... "delete_messages": True + ... "send_alert": True ... "infraction": { - ... "infraction_type": None, + ... "infraction_type": "", ... "infraction_reason": "", - ... "infraction_duration": None, + ... "infraction_duration": "0.0", ... } ... "channel_scope": { - ... "disabled_channels": None, - ... "disabled_categories": None, - ... "enabled_channels": None + ... "disabled_channels": [], + ... "disabled_categories": [], + ... "enabled_channels": [] ... } ... "mentions": { - ... "ping_type": None - ... "dm_ping_type": None + ... "ping_type": [ + ... "onduty" + ... ] + ... "dm_ping_type": [] + ... } + ... "server_message": { + ... "server_message_text": "", + ... "server_message_embed": "" ... } ... } @@ -193,11 +205,12 @@ class FilterViewSet(ModelViewSet): ... "settings": { ... "bypass_roles": None ... "filter_dm": None, - ... "enabled": False - ... "delete_messages": True + ... "enabled": None + ... "delete_messages": True, + ... "send_alert": True ... "infraction": { ... "infraction_type": None, - ... "infraction_reason": "", + ... "infraction_reason": None, ... "infraction_duration": None ... }, ... "channel_scope": { @@ -231,11 +244,12 @@ class FilterViewSet(ModelViewSet): ... "settings": { ... "bypass_roles": None ... "filter_dm": None, - ... "enabled": False - ... "delete_messages": True + ... "enabled": None + ... "delete_messages": True, + ... "send_alert": True ... "infraction": { ... "infraction_type": None, - ... "infraction_reason": "", + ... "infraction_reason": None, ... "infraction_duration": None ... }, ... "channel_scope": { -- cgit v1.2.3 From c1593a66e1fe8739ba881f76736330459d0ffc8e Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 23 Dec 2021 13:13:26 -0500 Subject: Fix markdown error linking to recommended editors --- .../guides/python-guides/creating-python-environment-windows.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/creating-python-environment-windows.md b/pydis_site/apps/content/resources/guides/python-guides/creating-python-environment-windows.md index 356d63bd..635c384f 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/creating-python-environment-windows.md +++ b/pydis_site/apps/content/resources/guides/python-guides/creating-python-environment-windows.md @@ -29,7 +29,7 @@ You will also need a text editor for writing Python programs, and for subsequent Powerful programs called integrated development environments (IDEs) like PyCharm and Visual Studio Code contain text editors, but they also contain many other features with uses that aren't immediately obvious to new programmers. [Notepad++](https://notepad-plus-plus.org/) is a popular text editor for both beginners and advanced users who prefer a simpler interface. -Other editors we recommend can be found (https://pythondiscord.com/resources/tools/#editors)[here]. +Other editors we recommend can be found [here](https://pythondiscord.com/resources/tools/#editors). ## Installing Git Bash Git is a command line program that helps you keep track of changes to your code, among other things. -- cgit v1.2.3 From 0dc2e2a1c8bb7f8ee51bc37a311d3b60473fbad6 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 23 Dec 2021 16:25:56 -0500 Subject: Update wording in topical and general help channel sections --- .../guides/pydis-guides/help-channel-guide.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md index fe1c4747..5b0c773c 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md @@ -22,17 +22,15 @@ If your question fits into the domain of one of our topical help channels, and i ![List of topical help channels](/static/images/content/help_channels/topical_channels.png) -Some of the topical help channels have a broad scope, so they can cover many (somewhat) related topics. +Some of the topical help channels have a broad scope, so they can cover many related topics. For example, `#data-science-and-ai` covers scientific Python, statistics, and machine learning, while `#algos-and-data-structs` covers everything from data structures and algorithms to maths. -To help you navigate this, we've added a list of suggested topics in the topic of every channel. -If you're not sure where to post, feel free to ask us which channel is relevant for a topic in `#community-meta`. +Each channel on the server has a channel description which briefly describes the topics covered by that channel. If you're not sure where to post, feel free to ask us which channel is appropriate in `#community-meta`. # General Help Channels -Our general help channels move at a fast pace, and attract a far more diverse spectrum of helpers. -This is a great choice for a generic Python question, and a good choice if you need an answer as soon as possible. -It's particularly important to [ask good questions](../asking-good-questions) when asking in these channels, or you risk not getting an answer and having your help channel be claimed by someone else. +Our general help channels cycle quickly, with the advantage of attracting a more diverse spectrum of helpers, and getting individual attention and help for your question. These channels are a great choice for generic Python questions, but they can be used for domain-specific Python help as well. +Be sure to [ask good questions](../asking-good-questions) in order to give yourself the best chances of getting help. ## How To Claim a Channel @@ -40,12 +38,12 @@ There are always 3 available help channels waiting to be claimed in the **Python ![Available help channels](/static/images/content/help_channels/available_channels.png) -In order to claim one, simply start typing your question into one of these channels. Once your question has been posted, you have claimed this channel, and the channel will be moved down to the **Python Help: Occupied** category. +This message is posted when a channel becomes available for use: +![Channel available message](/static/images/content/help_channels/available_message.png) -If you're unable to type into these channels, this means you're currently **on cooldown**. In order to prevent someone from claiming all the channels for themselves, **we only allow someone to claim a single help channel at the same time**. You can search in the top right corner of your Discord client `from:yourusername#xxxx` to find back your channel. +In order to claim one, simply start typing your question into one of these channels. Once your question is posted, you have claimed this channel it will be moved down to the **Occupied Help Channels** category. -![Channel available message](/static/images/content/help_channels/available_message.png) -*This message is always posted when a channel becomes available for use.* +At this point you will have the **Help Cooldown** role which will remain on your profile until your help channel is closed (with the `!closed` command) or goes dormant due to inactivity. ## Q: For how long is the channel mine? -- cgit v1.2.3 From 5403f81215c7ea51aaebfc29148f8c9104b92430 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 23 Dec 2021 16:29:23 -0500 Subject: Add Frequently Asked Questions header --- .../apps/content/resources/guides/pydis-guides/help-channel-guide.md | 2 ++ 1 file changed, 2 insertions(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md index 5b0c773c..0f5a4f9c 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md @@ -45,6 +45,8 @@ In order to claim one, simply start typing your question into one of these chann At this point you will have the **Help Cooldown** role which will remain on your profile until your help channel is closed (with the `!closed` command) or goes dormant due to inactivity. +# Frequently Asked Questions + ## Q: For how long is the channel mine? The channel is yours until it has been inactive for **10 minutes**, or 30 minutes until someone participate in the channel. When this happens, we move the channel down to the **Python Help: Dormant** category, and make the channel read-only. After a while, the channel will be rotated back into **Python Help: Available** for the next question. Please try to resist the urge to continue bumping the channel so that it never gets marked as inactive. If nobody is answering your question, you should try to reformulate the question to increase your chances of getting help. -- cgit v1.2.3 From 75cde82850b683a563a23cbed813bfcb0322e92e Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 23 Dec 2021 16:55:59 -0500 Subject: Update channel dormancy timings --- .../content/resources/guides/pydis-guides/help-channel-guide.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md index 0f5a4f9c..c0c7f896 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md @@ -47,12 +47,12 @@ At this point you will have the **Help Cooldown** role which will remain on your # Frequently Asked Questions -## Q: For how long is the channel mine? +## Q: How long does my help channel stay active? -The channel is yours until it has been inactive for **10 minutes**, or 30 minutes until someone participate in the channel. When this happens, we move the channel down to the **Python Help: Dormant** category, and make the channel read-only. After a while, the channel will be rotated back into **Python Help: Available** for the next question. Please try to resist the urge to continue bumping the channel so that it never gets marked as inactive. If nobody is answering your question, you should try to reformulate the question to increase your chances of getting help. +The channel remains open for **30 minutes** after your last message, but if another user posts in your channel, the closing will be delayed by 10 minutes. +*You'll see this message in your channel once it goes dormant:* ![Channel dormant message](/static/images/content/help_channels/dormant_message.png) -*You'll see this message in your channel when the channel is marked as inactive.* ## Q: I don't need my help channel anymore, my question was answered. What do I do? -- cgit v1.2.3 From 0219d94eb449f99dabf1f26dcf77f07ef92d13e0 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 23 Dec 2021 17:05:08 -0500 Subject: Add "no one answered me" question and answer --- .../content/resources/guides/pydis-guides/help-channel-guide.md | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md index c0c7f896..e74cc43f 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md @@ -54,6 +54,12 @@ The channel remains open for **30 minutes** after your last message, but if anot *You'll see this message in your channel once it goes dormant:* ![Channel dormant message](/static/images/content/help_channels/dormant_message.png) +## Q: No one answered my question. How come? + +The server has users active all over the world and all hours of the day, but some days are less active than others. If no one answered your question, feel free to claim another help channel a little later. + +If you still feel like your question is being overlooked, read our guide on [asking good questions](../asking-good-questions) to incease your chances of getting a response. + ## Q: I don't need my help channel anymore, my question was answered. What do I do? Once you have finished with your help channel you or a staff member can run `!dormant`. This will move the channel to the **Python Help: Dormant** category where it will sit until it is returned to circulation. You will only be able to run the command if you claimed the channel from the available category, you cannot close channels belonging to others. -- cgit v1.2.3 From 72234b1a2b6585e998d85cebd31d3b5ed7986cec Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 23 Dec 2021 17:12:18 -0500 Subject: Edit "how do I close my channel" question and answer --- .../apps/content/resources/guides/pydis-guides/help-channel-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md index e74cc43f..5f41f442 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md @@ -62,7 +62,7 @@ If you still feel like your question is being overlooked, read our guide on [ask ## Q: I don't need my help channel anymore, my question was answered. What do I do? -Once you have finished with your help channel you or a staff member can run `!dormant`. This will move the channel to the **Python Help: Dormant** category where it will sit until it is returned to circulation. You will only be able to run the command if you claimed the channel from the available category, you cannot close channels belonging to others. +Go ahead and move your channel to the dormant category by running the `!closed` command. You will only be able to run this command in your own help channel, and likewise no one will be able to prematurely close your claimed help channel (with the exception of staff). ## Q: Are only Helpers supposed to answer questions? -- cgit v1.2.3 From c2aaa8d672484a698b8aec6a65c2f4af3cff18b1 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Fri, 24 Dec 2021 14:15:52 +0100 Subject: Include 'dm_content ' field under Infraction settings in Filters/FilterLists --- pydis_site/apps/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 30af9512..66236d92 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -142,7 +142,7 @@ BASE_SETTINGS_FIELDS = ( "delete_messages", "send_alert" ) -INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration") +INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration", "dm_content") CHANNEL_SCOPE_FIELDS = ( "disabled_channels", "disabled_categories", -- cgit v1.2.3 From 466fc7cc4297fe4f5d921f6ca950b926ecc2d14d Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Fri, 24 Dec 2021 14:22:29 +0100 Subject: Correct 'Redirect' FilterLists' default values. --- .../0075_prepare_filter_and_filterlist_for_new_filter_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py index cc524fcb..56cbdedb 100644 --- a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py @@ -26,7 +26,7 @@ def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> N list_type=0, filter_dm=True, delete_messages=False, - bypass_roles=[0], + bypass_roles=["staff"], enabled=True ) redirects.save() -- cgit v1.2.3 From c082ad818608fd52238e61f9c69d99cfb2aa503b Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 25 Dec 2021 20:18:02 +0200 Subject: Merged infraction and notification settings in JSON The settings for infracting and notifying the user were merged under one field, which is renamed to "infraction_and_notification". The only place which sends a message in the server by default is the antimalware, the rest try to DM the user first, and antimalware can do the same. This avoids complications which may result from the filtering cog trying to send two messages: one for the defined server message, and another for a failed DM. --- .../0079_add_server_message_and_alert_fields.py | 22 ++++----------- pydis_site/apps/api/models/bot/filters.py | 25 ++++------------- pydis_site/apps/api/serializers.py | 28 ++++++++----------- pydis_site/apps/api/viewsets/bot/filters.py | 32 ++++++++-------------- 4 files changed, 34 insertions(+), 73 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py index f9803bd3..c6299cb9 100644 --- a/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py +++ b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py @@ -15,8 +15,7 @@ def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> N } for filter_list in FilterList.objects.all(): filter_list.send_alert = change_map.get(filter_list.name) - filter_list.server_message_text = "" - filter_list.server_message_embed = "" + filter_list.dm_embed = "" filter_list.save() @@ -24,7 +23,6 @@ def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> FilterList = apps.get_model("api", "FilterList") for filter_list in FilterList.objects.all(): filter_list.send_alert = True - filter_list.server_message_text = None filter_list.server_message_embed = None filter_list.save() @@ -42,13 +40,8 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='filter', - name='server_message_embed', - field=models.CharField(help_text='The content of the server message embed', max_length=100, null=True), - ), - migrations.AddField( - model_name='filter', - name='server_message_text', - field=models.CharField(help_text='The message to send on the server', max_length=100, null=True), + name='dm_embed', + field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True), ), migrations.AddField( model_name='filterlist', @@ -57,13 +50,8 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='filterlist', - name='server_message_embed', - field=models.CharField(help_text='The content of the server message embed', max_length=100, null=True), - ), - migrations.AddField( - model_name='filterlist', - name='server_message_text', - field=models.CharField(help_text='The message to send on the server', max_length=100, null=True), + name='dm_embed', + field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True), ), migrations.RunPython(migrate_filterlist, unmigrate_filterlist) ] diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 92251ee4..97af21f8 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -50,6 +50,11 @@ class FilterSettingsMixin(models.Model): null=True, help_text="The DM to send to a user triggering this filter." ) + dm_embed = models.CharField( + max_length=2000, + help_text="The content of the DM embed", + null=True + ) infraction_type = models.CharField( choices=Infraction.TYPE_CHOICES, max_length=9, @@ -111,16 +116,6 @@ class FilterList(FilterSettingsMixin): null=False, default=True ) - server_message_text = models.CharField( - max_length=100, - help_text="The message to send on the server", - null=True - ) - server_message_embed = models.CharField( - max_length=100, - help_text="The content of the server message embed", - null=True - ) # Where a filter should apply. # # The resolution is done in the following order: @@ -183,16 +178,6 @@ class Filter(FilterSettingsMixin): help_text="Whether alert should be sent.", null=True ) - server_message_text = models.CharField( - max_length=100, - help_text="The message to send on the server", - null=True - ) - server_message_embed = models.CharField( - max_length=100, - help_text="The content of the server message embed", - null=True - ) # Check FilterList model for information about these properties. enabled_channels = ArrayField(models.IntegerField(), null=True) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 66236d92..91aac822 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -142,13 +142,18 @@ BASE_SETTINGS_FIELDS = ( "delete_messages", "send_alert" ) -INFRACTION_FIELDS = ("infraction_type", "infraction_reason", "infraction_duration", "dm_content") +INFRACTION_AND_NOTIFICATION_FIELDS = ( + "infraction_type", + "infraction_reason", + "infraction_duration", + "dm_content", + "dm_embed" +) CHANNEL_SCOPE_FIELDS = ( "disabled_channels", "disabled_categories", "enabled_channels", ) -SERVER_MESSAGE_FIELDS = ("server_message_text", "server_message_embed") MENTIONS_FIELDS = ("ping_type", "dm_ping_type") SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS @@ -208,8 +213,10 @@ class FilterSerializer(ModelSerializer): schema_settings = { "settings": {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} - | {"infraction": {name: getattr(instance, name) for name in INFRACTION_FIELDS}} | { + "infraction_and_notification": + {name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS} + } | { "channel_scope": {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS} } | { @@ -219,12 +226,6 @@ class FilterSerializer(ModelSerializer): for schema_field_name in MENTIONS_FIELDS } } - } | { - "server_message": - { - schema_field_name: getattr(instance, schema_field_name) - for schema_field_name in SERVER_MESSAGE_FIELDS - } } schema_base = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS} | \ {"filter_list": instance.filter_list.id} @@ -306,8 +307,8 @@ class FilterListSerializer(ModelSerializer): | {"filters": filters} schema_settings_base = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} schema_settings_categories = { - "infraction": - {name: getattr(instance, name) for name in INFRACTION_FIELDS}} \ + "infraction_and_notification": + {name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS}} \ | { "channel_scope": {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}} | { @@ -315,11 +316,6 @@ class FilterListSerializer(ModelSerializer): schema_field_name: getattr(instance, schema_field_name) for schema_field_name in MENTIONS_FIELDS } - } | { - "server_message": { - schema_field_name: getattr(instance, schema_field_name) - for schema_field_name in SERVER_MESSAGE_FIELDS - } } return schema_base | {"settings": schema_settings_base | schema_settings_categories} diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index e52cd4e5..dd9a7d87 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -37,10 +37,12 @@ class FilterListViewSet(ModelViewSet): ... "enabled": None ... "send_alert": True, ... "delete_messages": None - ... "infraction": { + ... "infraction_and_notification": { ... "infraction_type": None, ... "infraction_reason": "", ... "infraction_duration": None + ... "dm_content": None, + ... "dm_embed": None ... }, ... "channel_scope": { ... "disabled_channels": None, @@ -51,10 +53,6 @@ class FilterListViewSet(ModelViewSet): ... "ping_type": None ... "dm_ping_type": None ... } - ... "server_message": { - ... "server_message_text": None, - ... "server_message_embed": None - ... } ... } ... ... }, @@ -68,10 +66,12 @@ class FilterListViewSet(ModelViewSet): ... "enabled": True ... "delete_messages": True, ... "send_alert": True - ... "infraction": { + ... "infraction_and_notification": { ... "infraction_type": "", ... "infraction_reason": "", ... "infraction_duration": "0.0", + ... "dm_content": "", + ... "dm_embed": "" ... } ... "channel_scope": { ... "disabled_channels": [], @@ -84,10 +84,6 @@ class FilterListViewSet(ModelViewSet): ... ] ... "dm_ping_type": [] ... } - ... "server_message": { - ... "server_message_text": "", - ... "server_message_embed": "" - ... } ... }, ... ... ... ] @@ -117,10 +113,12 @@ class FilterListViewSet(ModelViewSet): ... "enabled": None ... "delete_messages": None, ... "send_alert": None - ... "infraction": { + ... "infraction_and_notification": { ... "infraction_type": None, ... "infraction_reason": "", ... "infraction_duration": None + ... "dm_content": None, + ... "dm_embed": None ... }, ... "channel_scope": { ... "disabled_channels": None, @@ -131,10 +129,6 @@ class FilterListViewSet(ModelViewSet): ... "ping_type": None ... "dm_ping_type": None ... } - ... "server_message": { - ... "server_message_text": None, - ... "server_message_embed": None - ... } ... } ... ... }, @@ -148,10 +142,12 @@ class FilterListViewSet(ModelViewSet): ... "enabled": True ... "delete_messages": True ... "send_alert": True - ... "infraction": { + ... "infraction_and_notification": { ... "infraction_type": "", ... "infraction_reason": "", ... "infraction_duration": "0.0", + ... "dm_content": "", + ... "dm_embed": "" ... } ... "channel_scope": { ... "disabled_channels": [], @@ -164,10 +160,6 @@ class FilterListViewSet(ModelViewSet): ... ] ... "dm_ping_type": [] ... } - ... "server_message": { - ... "server_message_text": "", - ... "server_message_embed": "" - ... } ... } #### Status codes -- cgit v1.2.3 From 78e91c433b193682d82bbeecd6e73c2b01964b3d Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 1 Jan 2022 14:41:36 +0200 Subject: Merge migrations and correct filter defaults to be all null --- .../apps/api/migrations/0070_new_filter_schema.py | 4 +- .../0079_add_server_message_and_alert_fields.py | 57 --------------------- .../migrations/0079_dm_embed_and_alert_fields.py | 58 ++++++++++++++++++++++ 3 files changed, 60 insertions(+), 59 deletions(-) delete mode 100644 pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py create mode 100644 pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index f56c29f8..2c15605c 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -60,7 +60,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: enabled=None, dm_content=None, infraction_type=None, - infraction_reason="", + infraction_reason=None, infraction_duration=None, disallowed_channels=None, disallowed_categories=None, @@ -96,7 +96,7 @@ class Migration(migrations.Migration): ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)), ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), - ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), + ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True)), ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), diff --git a/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py deleted file mode 100644 index c6299cb9..00000000 --- a/pydis_site/apps/api/migrations/0079_add_server_message_and_alert_fields.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 3.1.14 on 2021-12-19 23:05 -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - - -def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: - FilterList = apps.get_model("api", "FilterList") - change_map = { - "tokens": True, - "domains": True, - "invites": True, - "extensions": False, - "redirects": False - } - for filter_list in FilterList.objects.all(): - filter_list.send_alert = change_map.get(filter_list.name) - filter_list.dm_embed = "" - filter_list.save() - - -def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: - FilterList = apps.get_model("api", "FilterList") - for filter_list in FilterList.objects.all(): - filter_list.send_alert = True - filter_list.server_message_embed = None - filter_list.save() - - -class Migration(migrations.Migration): - dependencies = [ - ('api', '0078_merge_20211218_2200'), - ] - - operations = [ - migrations.AddField( - model_name='filter', - name='send_alert', - field=models.BooleanField(help_text='Whether alert should be sent.', null=True), - ), - migrations.AddField( - model_name='filter', - name='dm_embed', - field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True), - ), - migrations.AddField( - model_name='filterlist', - name='send_alert', - field=models.BooleanField(default=True, help_text='Whether alert should be sent.'), - ), - migrations.AddField( - model_name='filterlist', - name='dm_embed', - field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True), - ), - migrations.RunPython(migrate_filterlist, unmigrate_filterlist) - ] diff --git a/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py new file mode 100644 index 00000000..49da62b6 --- /dev/null +++ b/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py @@ -0,0 +1,58 @@ +# Generated by Django 3.1.14 on 2021-12-19 23:05 +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + FilterList = apps.get_model("api", "FilterList") + change_map = { + "tokens": True, + "domains": True, + "invites": True, + "extensions": False, + "redirects": False + } + for filter_list in FilterList.objects.all(): + filter_list.send_alert = change_map.get(filter_list.name) + filter_list.dm_embed = "" + filter_list.save() + + +def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + FilterList = apps.get_model("api", "FilterList") + for filter_list in FilterList.objects.all(): + filter_list.send_alert = True + filter_list.server_message_embed = None + filter_list.save() + + +class Migration(migrations.Migration): + dependencies = [ + ('api', '0078_merge_20211213_0552'), + ('api', '0078_merge_20211218_2200'), + ] + + operations = [ + migrations.AddField( + model_name='filter', + name='send_alert', + field=models.BooleanField(help_text='Whether alert should be sent.', null=True), + ), + migrations.AddField( + model_name='filter', + name='dm_embed', + field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True), + ), + migrations.AddField( + model_name='filterlist', + name='send_alert', + field=models.BooleanField(default=True, help_text='Whether alert should be sent.'), + ), + migrations.AddField( + model_name='filterlist', + name='dm_embed', + field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True), + ), + migrations.RunPython(migrate_filterlist, unmigrate_filterlist) + ] -- cgit v1.2.3 From 7cbc5dc1cfce844a9620ba902a7d576079e58c06 Mon Sep 17 00:00:00 2001 From: Steele Date: Fri, 11 Feb 2022 00:37:02 -0500 Subject: Drop "in Python" from title. While the title of the book is "Neural Networks from Scratch in Python", it's typically referred to as "Neural Networks from Scratch", and it goes without saying in this context that Python will be used in code examples. --- .../resources/resources/neural_networks_from_scratch_in_python.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml b/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml index c4ad1e1b..26e88cb9 100644 --- a/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml +++ b/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml @@ -2,7 +2,7 @@ description: '"Neural Networks From Scratch" is a book intended to teach you how without any libraries, so you can better understand deep learning and how all of the elements work. This is so you can go out and do new/novel things with deep learning as well as to become more successful with even more basic models. This book is to accompany the usual free tutorial videos and sample code from youtube.com/sentdex.' -name: Neural Networks from Scratch in Python +name: Neural Networks from Scratch title_url: https://nnfs.io/ urls: - icon: branding/goodreads -- cgit v1.2.3 From 28fbcbf81cea41df0686a409cf71a45121899b7c Mon Sep 17 00:00:00 2001 From: Steele Date: Fri, 11 Feb 2022 00:46:52 -0500 Subject: Add PyCharm logo. Same URL used in `jetbrains_videos.yaml`. --- pydis_site/apps/resources/resources/pycharm.yaml | 1 + 1 file changed, 1 insertion(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/pycharm.yaml b/pydis_site/apps/resources/resources/pycharm.yaml index 574158bc..e8c787e6 100644 --- a/pydis_site/apps/resources/resources/pycharm.yaml +++ b/pydis_site/apps/resources/resources/pycharm.yaml @@ -1,6 +1,7 @@ description: The very best Python IDE, with a wealth of advanced features and convenience functions. name: PyCharm +title_image: https://resources.jetbrains.com/storage/products/pycharm/img/meta/pycharm_logo_300x300.png title_url: https://www.jetbrains.com/pycharm/ tags: topics: -- cgit v1.2.3 From 96915762e18d047be49ba941e62e67756e464ba5 Mon Sep 17 00:00:00 2001 From: mathstrains21 Date: Fri, 11 Feb 2022 18:09:45 +0000 Subject: Update Exercism Resource (#658) --- pydis_site/apps/resources/resources/exercism.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/exercism.yaml b/pydis_site/apps/resources/resources/exercism.yaml index b8f53d72..c623db2d 100644 --- a/pydis_site/apps/resources/resources/exercism.yaml +++ b/pydis_site/apps/resources/resources/exercism.yaml @@ -2,8 +2,8 @@ description: Level up your programming skills with more than 2600 exercises acro 47 programming languages, Python included. The website provides a mentored mode, where you can get your code reviewed for each solution you submit. The mentors will give you insightful advice to make you a better programmer. -name: exercism.io -title_url: https://exercism.io/ +name: Exercism +title_url: https://exercism.org/ urls: - icon: branding/github url: https://github.com/exercism/python -- cgit v1.2.3 From 74cfc67569d82c437ad71ff6266ef72fed47531f Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 12 Feb 2022 12:24:15 +0100 Subject: Kaizen: Fix redirect /pages/resources/communities. --- pydis_site/apps/redirect/redirects.yaml | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/redirect/redirects.yaml b/pydis_site/apps/redirect/redirects.yaml index 9b64011b..4a48ba0c 100644 --- a/pydis_site/apps/redirect/redirects.yaml +++ b/pydis_site/apps/redirect/redirects.yaml @@ -83,6 +83,11 @@ good_questions_redirect_alt: redirect_arguments: ["guides/pydis-guides/asking-good-questions"] # Resources +resources_old_communities_redirect: + original_path: pages/resources/communities/ + redirect_route: "resources:index" + redirect_arguments: ["community"] + resources_index_redirect: original_path: pages/resources/ redirect_route: "resources:index" -- cgit v1.2.3 From c412d29877aef4394a5d87a7b0f338307f5c5e8d Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 12 Feb 2022 17:39:31 +0100 Subject: Kaizen: Update URL for Two Scoops. --- pydis_site/apps/resources/resources/two_scoops_of_django.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/two_scoops_of_django.yaml b/pydis_site/apps/resources/resources/two_scoops_of_django.yaml index 96eafd28..f372d35d 100644 --- a/pydis_site/apps/resources/resources/two_scoops_of_django.yaml +++ b/pydis_site/apps/resources/resources/two_scoops_of_django.yaml @@ -1,7 +1,7 @@ description: Tips, tricks, and best practices for your Django project. A highly recommended resource for Django web developers. name: Two Scoops of Django -title_url: https://www.feldroy.com/collections/everything/products/two-scoops-of-django-3-x +title_url: https://www.feldroy.com/books/two-scoops-of-django-3-x urls: - icon: branding/goodreads url: https://www.goodreads.com/book/show/55822151-two-scoops-of-django-3-x -- cgit v1.2.3 From 4defa4a90628044d1195d6894961bcc2aa56edb2 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 11:44:57 +0100 Subject: Add names for every resource card. --- pydis_site/apps/resources/resources/adafruit.yaml | 1 + pydis_site/apps/resources/resources/corey_schafer.yaml | 1 + pydis_site/apps/resources/resources/kivy.yaml | 1 + pydis_site/apps/resources/resources/microsoft.yaml | 1 + pydis_site/apps/resources/resources/pallets.yaml | 1 + pydis_site/apps/resources/resources/panda3d.yaml | 1 + pydis_site/apps/resources/resources/people_postgres_data.yaml | 1 + pydis_site/apps/resources/resources/pyglet.yaml | 1 + pydis_site/apps/resources/resources/python_discord_videos.yaml | 1 + pydis_site/apps/resources/resources/real_python.yaml | 1 + pydis_site/apps/resources/resources/sentdex.yaml | 1 + pydis_site/apps/resources/resources/socratica.yaml | 1 + pydis_site/templates/resources/resource_box.html | 2 +- pydis_site/templates/resources/resource_box_header.html | 3 +-- 14 files changed, 14 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/adafruit.yaml b/pydis_site/apps/resources/resources/adafruit.yaml index f9466bd8..c687f507 100644 --- a/pydis_site/apps/resources/resources/adafruit.yaml +++ b/pydis_site/apps/resources/resources/adafruit.yaml @@ -1,3 +1,4 @@ +name: Adafruit description: Adafruit is an open-source electronics manufacturer that makes all the components you need to start your own Python-powered hardware projects. Their official community host regular show-and-tells, diff --git a/pydis_site/apps/resources/resources/corey_schafer.yaml b/pydis_site/apps/resources/resources/corey_schafer.yaml index f5af2cab..d66ea004 100644 --- a/pydis_site/apps/resources/resources/corey_schafer.yaml +++ b/pydis_site/apps/resources/resources/corey_schafer.yaml @@ -1,3 +1,4 @@ +name: Corey Schafer description: 'Corey has a number of exceptionally high quality tutorial series on everything from Python basics to Django and Flask:
    diff --git a/pydis_site/apps/resources/resources/kivy.yaml b/pydis_site/apps/resources/resources/kivy.yaml index 47ff07ad..b1f57483 100644 --- a/pydis_site/apps/resources/resources/kivy.yaml +++ b/pydis_site/apps/resources/resources/kivy.yaml @@ -1,3 +1,4 @@ +name: Kivy description: The Kivy project, through the Kivy framework and its sister projects, aims to provide all the tools to create desktop and mobile applications in Python. Allowing rapid development of multitouch applications with custom and exciting user interfaces. diff --git a/pydis_site/apps/resources/resources/microsoft.yaml b/pydis_site/apps/resources/resources/microsoft.yaml index e1d62955..290283cc 100644 --- a/pydis_site/apps/resources/resources/microsoft.yaml +++ b/pydis_site/apps/resources/resources/microsoft.yaml @@ -1,3 +1,4 @@ +name: Microsoft Python description: Microsoft Python is a Discord server for discussing all things relating to using Python with Microsoft products, they have channels for Azure, VS Code, IoT, Data Science and much more! title_image: https://1000logos.net/wp-content/uploads/2017/04/Microsoft-Logo.png diff --git a/pydis_site/apps/resources/resources/pallets.yaml b/pydis_site/apps/resources/resources/pallets.yaml index 0da2a625..a330b756 100644 --- a/pydis_site/apps/resources/resources/pallets.yaml +++ b/pydis_site/apps/resources/resources/pallets.yaml @@ -1,3 +1,4 @@ +name: Pallets Projects description: The Pallets Projects develop Python libraries such as the Flask web framework, the Jinja templating library, and the Click command line toolkit. Join to discuss and get help from the Pallets community. diff --git a/pydis_site/apps/resources/resources/panda3d.yaml b/pydis_site/apps/resources/resources/panda3d.yaml index 2040450d..eeb54465 100644 --- a/pydis_site/apps/resources/resources/panda3d.yaml +++ b/pydis_site/apps/resources/resources/panda3d.yaml @@ -1,3 +1,4 @@ +name: Panda3D description: Panda3D is a Python-focused 3-D framework for rapid development of games, visualizations, and simulations, written in C++ with an emphasis on performance and flexibility. title_image: https://www.panda3d.org/wp-content/uploads/2019/01/panda3d_logo.png diff --git a/pydis_site/apps/resources/resources/people_postgres_data.yaml b/pydis_site/apps/resources/resources/people_postgres_data.yaml index 46db7095..9fec6634 100644 --- a/pydis_site/apps/resources/resources/people_postgres_data.yaml +++ b/pydis_site/apps/resources/resources/people_postgres_data.yaml @@ -1,3 +1,4 @@ +name: People, Postgres, Data description: People, Postgres, Data specializes in building users of Postgres and related ecosystem including but not limited to technologies such as RDS Postgres, Aurora for Postgres, Google Postgres, PostgreSQL.Org Postgres, Greenplum, Timescale and ZomboDB. diff --git a/pydis_site/apps/resources/resources/pyglet.yaml b/pydis_site/apps/resources/resources/pyglet.yaml index a47c7e62..bdfb84cf 100644 --- a/pydis_site/apps/resources/resources/pyglet.yaml +++ b/pydis_site/apps/resources/resources/pyglet.yaml @@ -1,3 +1,4 @@ +name: Pyglet description: Pyglet is a powerful, yet easy to use Python library for developing games and other visually-rich applications on Windows, Mac OS X and Linux. It supports windowing, user interface event handling, Joysticks, OpenGL graphics, diff --git a/pydis_site/apps/resources/resources/python_discord_videos.yaml b/pydis_site/apps/resources/resources/python_discord_videos.yaml index 15a04097..b1f0b3a5 100644 --- a/pydis_site/apps/resources/resources/python_discord_videos.yaml +++ b/pydis_site/apps/resources/resources/python_discord_videos.yaml @@ -1,3 +1,4 @@ +name: Python Discord YouTube channel description: It's our YouTube channel! We are slowly gathering content here directly related to Python, our community and the events we host. Come check us out! title_image: https://raw.githubusercontent.com/python-discord/branding/master/logos/logo_banner/logo_site_banner_dark_512.png diff --git a/pydis_site/apps/resources/resources/real_python.yaml b/pydis_site/apps/resources/resources/real_python.yaml index 2ddada03..93953004 100644 --- a/pydis_site/apps/resources/resources/real_python.yaml +++ b/pydis_site/apps/resources/resources/real_python.yaml @@ -1,3 +1,4 @@ +name: Real Python description: Dan Bader's treasure trove of quizzes, tutorials and interactive content for learning Python. An absolute goldmine. title_image: https://i.imgur.com/WDqhZ36.png diff --git a/pydis_site/apps/resources/resources/sentdex.yaml b/pydis_site/apps/resources/resources/sentdex.yaml index 4f4712ac..7cb0a8a4 100644 --- a/pydis_site/apps/resources/resources/sentdex.yaml +++ b/pydis_site/apps/resources/resources/sentdex.yaml @@ -1,3 +1,4 @@ +name: Sentdex description: 'An enormous amount of Python content for all skill levels from the most popular Python YouTuber on the web.
      diff --git a/pydis_site/apps/resources/resources/socratica.yaml b/pydis_site/apps/resources/resources/socratica.yaml index 43d033c0..45150b33 100644 --- a/pydis_site/apps/resources/resources/socratica.yaml +++ b/pydis_site/apps/resources/resources/socratica.yaml @@ -1,3 +1,4 @@ +name: Socratica description: 'Socratica is a small studio focused on producing high quality STEM-related educational content, including a series about Python. Their videos star actress Ulka Simone Mohanty, who plays an android-like instructor explaining fundamental concepts in a concise and entertaining way.' diff --git a/pydis_site/templates/resources/resource_box.html b/pydis_site/templates/resources/resource_box.html index e26203e9..5189bb3e 100644 --- a/pydis_site/templates/resources/resource_box.html +++ b/pydis_site/templates/resources/resource_box.html @@ -2,7 +2,7 @@ {% load to_kebabcase %} {% load get_category_icon %} -
      +
      {% if 'title_url' in resource %} {% include "resources/resource_box_header.html" %} diff --git a/pydis_site/templates/resources/resource_box_header.html b/pydis_site/templates/resources/resource_box_header.html index 84e1a79b..dfbdd92f 100644 --- a/pydis_site/templates/resources/resource_box_header.html +++ b/pydis_site/templates/resources/resource_box_header.html @@ -17,8 +17,7 @@ {% if 'title_image' in resource %} - {% endif %} - {% if 'name' in resource %} + {% elif 'name' in resource %} {{ resource.name }} {% endif %} -- cgit v1.2.3 From 6c9fb075dc52d5674f90698cb74138712f7d99e1 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 30 Jan 2022 15:34:04 +0100 Subject: Allow searching users by username and discriminator A test case is added to demonstrate this functionality. Closes #578. Co-authored-by: Boris Muratov <8bee278@gmail.com> --- pydis_site/apps/api/tests/test_users.py | 43 ++++++++++++++++++++++++++++++++ pydis_site/apps/api/viewsets/bot/user.py | 5 ++++ 2 files changed, 48 insertions(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py index e21bb32b..5d10069d 100644 --- a/pydis_site/apps/api/tests/test_users.py +++ b/pydis_site/apps/api/tests/test_users.py @@ -1,3 +1,4 @@ +import random from unittest.mock import Mock, patch from django.urls import reverse @@ -520,3 +521,45 @@ class UserMetricityTests(AuthenticatedAPITestCase): self.metricity.total_messages.side_effect = NotFoundError() self.metricity.total_message_blocks.side_effect = NotFoundError() self.metricity.top_channel_activity.side_effect = NotFoundError() + + +class UserViewSetTests(AuthenticatedAPITestCase): + @classmethod + def setUpTestData(cls): + cls.searched_user = User.objects.create( + id=12095219, + name=f"Test user {random.randint(100, 1000)}", + discriminator=random.randint(1, 9999), + in_guild=True, + ) + cls.other_user = User.objects.create( + id=18259125, + name=f"Test user {random.randint(100, 1000)}", + discriminator=random.randint(1, 9999), + in_guild=True, + ) + + def test_search_lookup_of_wanted_user(self) -> None: + """Searching a user by name and discriminator should return that user.""" + url = reverse('api:bot:user-list') + params = { + 'username': self.searched_user.name, + 'discriminator': self.searched_user.discriminator, + } + response = self.client.get(url, params) + result = response.json() + self.assertEqual(result['count'], 1) + [user] = result['results'] + self.assertEqual(user['id'], self.searched_user.id) + + def test_search_lookup_of_unknown_user(self) -> None: + """Searching an unknown user should return no results.""" + url = reverse('api:bot:user-list') + params = { + 'username': "f-string enjoyer", + 'discriminator': 1245, + } + response = self.client.get(url, params) + result = response.json() + self.assertEqual(result['count'], 0) + self.assertEqual(result['results'], []) diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py index a867a80f..3318b2b9 100644 --- a/pydis_site/apps/api/viewsets/bot/user.py +++ b/pydis_site/apps/api/viewsets/bot/user.py @@ -2,6 +2,7 @@ import typing from collections import OrderedDict from django.db.models import Q +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import status from rest_framework.decorators import action from rest_framework.pagination import PageNumberPagination @@ -77,6 +78,8 @@ class UserViewSet(ModelViewSet): ... } #### Optional Query Parameters + - username: username to search for + - discriminator: discriminator to search for - page_size: number of Users in one page, defaults to 10,000 - page: page number @@ -233,6 +236,8 @@ class UserViewSet(ModelViewSet): serializer_class = UserSerializer queryset = User.objects.all().order_by("id") pagination_class = UserListPagination + filter_backends = (DjangoFilterBackend,) + filter_fields = ('name', 'discriminator') def get_serializer(self, *args, **kwargs) -> ModelSerializer: """Set Serializer many attribute to True if request body contains a list.""" -- cgit v1.2.3 From 0d9faf41d7b1bf743ec15578f7b91684316709c4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 13 Feb 2022 19:50:27 +0100 Subject: Titlecase the PyDis YouTube resource Co-authored-by: Mark <1515135+MarkKoz@users.noreply.github.com> --- pydis_site/apps/resources/resources/python_discord_videos.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/python_discord_videos.yaml b/pydis_site/apps/resources/resources/python_discord_videos.yaml index b1f0b3a5..012ec8ea 100644 --- a/pydis_site/apps/resources/resources/python_discord_videos.yaml +++ b/pydis_site/apps/resources/resources/python_discord_videos.yaml @@ -1,4 +1,4 @@ -name: Python Discord YouTube channel +name: Python Discord YouTube Channel description: It's our YouTube channel! We are slowly gathering content here directly related to Python, our community and the events we host. Come check us out! title_image: https://raw.githubusercontent.com/python-discord/branding/master/logos/logo_banner/logo_site_banner_dark_512.png -- cgit v1.2.3 From f30e1d9e4fc420085a1187fa12ac23efccd21663 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Tue, 15 Feb 2022 22:05:20 +0200 Subject: Allow filter descriptions to be null --- pydis_site/apps/api/migrations/0070_new_filter_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py index 2c15605c..f33c112b 100644 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0070_new_filter_schema.py @@ -50,7 +50,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: new_object = filter_.objects.create( content=object_.content, filter_list=list_, - description=object_.comment or "", + description=object_.comment, additional_field=None, ping_type=None, filter_dm=None, @@ -86,7 +86,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), - ('description', models.CharField(help_text='Why this filter has been added.', max_length=200)), + ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)), ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)), ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), -- cgit v1.2.3 From a28cdded7dabb62d639125dca2320234263809c2 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Tue, 15 Feb 2022 22:11:40 +0200 Subject: Use singular nouns for filter list names --- .../0075_prepare_filter_and_filterlist_for_new_filter_schema.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py index 56cbdedb..2a85fa63 100644 --- a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py @@ -7,10 +7,10 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: FilterList = apps.get_model("api", "FilterList") change_map = { - "filter_token": "tokens", - "domain_name": "domains", - "guild_invite": "invites", - "file_format": "extensions" + "filter_token": "token", + "domain_name": "domain", + "guild_invite": "invite", + "file_format": "extension" } for filter_list in FilterList.objects.all(): if change_map.get(filter_list.name): -- cgit v1.2.3 From 7d22d8427fa73e6209ffcea827d9e460b6c1d985 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Tue, 15 Feb 2022 22:59:04 +0100 Subject: Patch a minor issue with FilterList field naming in migrations --- ...75_prepare_filter_and_filterlist_for_new_filter_schema.py | 12 ++++++------ .../apps/api/migrations/0079_dm_embed_and_alert_fields.py | 10 +++++----- pydis_site/apps/api/serializers.py | 3 ++- 3 files changed, 13 insertions(+), 12 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py index 2a85fa63..1e24b379 100644 --- a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py @@ -17,7 +17,7 @@ def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> N filter_list.name = change_map.get(filter_list.name) filter_list.save() redirects = FilterList( - name="redirects", + name="redirect", ping_type=[], dm_ping_type=[], enabled_channels=[], @@ -35,16 +35,16 @@ def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> N def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: FilterList = apps.get_model("api", "FilterList") change_map = { - "tokens": "filter_token", - "domains": "domain_name", - "invites": "guild_invite", - "formats": "file_format" + "token": "filter_token", + "domain": "domain_name", + "invite": "guild_invite", + "format": "file_format" } for filter_list in FilterList.objects.all(): if change_map.get(filter_list.name): filter_list.name = change_map.get(filter_list.name) filter_list.save() - FilterList.objects.filter(name="redirects").delete() + FilterList.objects.filter(name="redirect").delete() class Migration(migrations.Migration): diff --git a/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py index 49da62b6..cae175df 100644 --- a/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py +++ b/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py @@ -7,11 +7,11 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: FilterList = apps.get_model("api", "FilterList") change_map = { - "tokens": True, - "domains": True, - "invites": True, - "extensions": False, - "redirects": False + "token": True, + "domain": True, + "invite": True, + "extension": False, + "redirect": False } for filter_list in FilterList.objects.all(): filter_list.send_alert = change_map.get(filter_list.name) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 88b6e2bd..99f2b630 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -215,7 +215,8 @@ class FilterSerializer(ModelSerializer): {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} | { "infraction_and_notification": - {name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS} + {name: getattr(instance, name) + for name in INFRACTION_AND_NOTIFICATION_FIELDS} } | { "channel_scope": {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS} -- cgit v1.2.3 From 15cac33dd74219a762d3c066efca383552f666b0 Mon Sep 17 00:00:00 2001 From: Rohan Reddy Alleti Date: Thu, 17 Feb 2022 02:55:28 +0530 Subject: Return random off topic names which are Active only (#644) Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- .../apps/api/tests/test_off_topic_channel_names.py | 35 +++++++++++++++++----- .../api/viewsets/bot/off_topic_channel_name.py | 3 +- 2 files changed, 29 insertions(+), 9 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_off_topic_channel_names.py b/pydis_site/apps/api/tests/test_off_topic_channel_names.py index 2d273756..34098c92 100644 --- a/pydis_site/apps/api/tests/test_off_topic_channel_names.py +++ b/pydis_site/apps/api/tests/test_off_topic_channel_names.py @@ -74,6 +74,9 @@ class ListTests(AuthenticatedAPITestCase): cls.test_name_3 = OffTopicChannelName.objects.create( name="frozen-with-iceman", used=True, active=False ) + cls.test_name_4 = OffTopicChannelName.objects.create( + name="xith-is-cool", used=True, active=True + ) def test_returns_name_in_list(self): """Return all off-topic channel names.""" @@ -86,28 +89,46 @@ class ListTests(AuthenticatedAPITestCase): { self.test_name.name, self.test_name_2.name, - self.test_name_3.name + self.test_name_3.name, + self.test_name_4.name } ) - def test_returns_two_items_with_random_items_param_set_to_2(self): - """Return not-used name instead used.""" + def test_returns_two_active_items_with_random_items_param_set_to_2(self): + """Return not-used active names instead used.""" url = reverse('api:bot:offtopicchannelname-list') response = self.client.get(f'{url}?random_items=2') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json()), 2) - self.assertEqual(set(response.json()), {self.test_name.name, self.test_name_2.name}) + self.assertTrue( + all( + item in (self.test_name.name, self.test_name_2.name, self.test_name_4.name) + for item in response.json() + ) + ) + + def test_returns_three_active_items_with_random_items_param_set_to_3(self): + """Return not-used active names instead used.""" + url = reverse('api:bot:offtopicchannelname-list') + response = self.client.get(f'{url}?random_items=3') + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 3) + self.assertEqual( + set(response.json()), + {self.test_name.name, self.test_name_2.name, self.test_name_4.name} + ) def test_running_out_of_names_with_random_parameter(self): - """Reset names `used` parameter to `False` when running out of names.""" + """Reset names `used` parameter to `False` when running out of active names.""" url = reverse('api:bot:offtopicchannelname-list') response = self.client.get(f'{url}?random_items=3') self.assertEqual(response.status_code, 200) self.assertEqual( set(response.json()), - {self.test_name.name, self.test_name_2.name, self.test_name_3.name} + {self.test_name.name, self.test_name_2.name, self.test_name_4.name} ) def test_returns_inactive_ot_names(self): @@ -129,7 +150,7 @@ class ListTests(AuthenticatedAPITestCase): self.assertEqual(response.status_code, 200) self.assertEqual( set(response.json()), - {self.test_name.name, self.test_name_2.name} + {self.test_name.name, self.test_name_2.name, self.test_name_4.name} ) diff --git a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py index 78f8c340..d0519e86 100644 --- a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py +++ b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py @@ -108,7 +108,7 @@ class OffTopicChannelNameViewSet(ModelViewSet): 'random_items': ["Must be a positive integer."] }) - queryset = self.queryset.order_by('used', '?')[:random_count] + queryset = self.queryset.filter(active=True).order_by('used', '?')[:random_count] # When any name is used in our listing then this means we reached end of round # and we need to reset all other names `used` to False @@ -133,7 +133,6 @@ class OffTopicChannelNameViewSet(ModelViewSet): return Response(serialized.data) params = {} - if active_param := request.query_params.get("active"): params["active"] = active_param.lower() == "true" -- cgit v1.2.3 From db1985a84e0f12feb177ef8ba5de5c2565bc4b5d Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Wed, 16 Feb 2022 14:27:19 -0700 Subject: Fix typo in bot tutorial resource (#661) Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') 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 32476dab..61a7b6f6 100644 --- a/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml +++ b/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml @@ -1,6 +1,6 @@ description: This tutorial, written by Python Discord staff member vcokltfre, will walk you through all the aspects of creating your own Discord bot, - starting from from creating the bot user itself. + starting from creating the bot user itself. name: vcokltfre's Discord Bot Tutorial title_url: https://tutorial.vcokltfre.dev/ tags: -- cgit v1.2.3 From 78553a9ecf2d4584d254eee313c0b8f78fe0e8ac Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sat, 19 Feb 2022 18:10:29 +0000 Subject: Remove expired coupon code and update course name --- .../apps/resources/resources/automate_the_boring_stuff_course.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/automate_the_boring_stuff_course.yaml b/pydis_site/apps/resources/resources/automate_the_boring_stuff_course.yaml index 133033f7..4632f5bd 100644 --- a/pydis_site/apps/resources/resources/automate_the_boring_stuff_course.yaml +++ b/pydis_site/apps/resources/resources/automate_the_boring_stuff_course.yaml @@ -1,7 +1,6 @@ description: The interactive course version of Al Sweigart's excellent book for beginners, taught by the author himself. - This link has a discounted version of the course which will always cost 10 dollars. Thanks, Al! -name: Automate the Boring Stuff with Python -title_url: https://www.udemy.com/automate/?couponCode=FOR_LIKE_10_BUCKS +name: Automate the Boring Stuff with Python Udemy Course +title_url: https://www.udemy.com/automate/ tags: topics: - general -- cgit v1.2.3 From 44d4895a86fb93ff91c5adc577f57073758b8c08 Mon Sep 17 00:00:00 2001 From: minalike Date: Sun, 20 Feb 2022 18:13:11 -0500 Subject: Update Name & Profile Policy Update to better reflect our current moderation practices in regards to user profiles. Previous name was "Nickname Policy" --- pydis_site/apps/content/resources/rules.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/rules.md b/pydis_site/apps/content/resources/rules.md index ef6cc4d1..b788c81b 100644 --- a/pydis_site/apps/content/resources/rules.md +++ b/pydis_site/apps/content/resources/rules.md @@ -10,21 +10,21 @@ We have a small but strict set of rules on our server. Please read over them and > 3. Respect staff members and listen to their instructions. > 4. Use English to the best of your ability. Be polite if someone speaks English imperfectly. > 5. Do not provide or request help on projects that may break laws, breach terms of services, or are malicious or inappropriate. -> 6. Do not post unapproved advertising. -> 7. Keep discussions relevant to the channel topic. Each channel's description tells you the topic. +> 6. Do not post unapproved advertising. +> 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. -# Nickname Policy +# Name & Profile Policy -In order to keep things pleasant and workable for both users and staff members, we enforce the following requirements regarding your nickname. +In order to keep things pleasant and workable for both users and staff members, we enforce the following requirements regarding your name, avatar, and profile. Staff reserve the right to change any nickname we judge to be violating these requirements. -1. No blank or "invisible" names -2. No slurs or other offensive sentiments -3. No noisy unicode characters - for example, z̯̯͡a̧͎̺̻̝͕̠l̡͓̫̣g̹̲o̡̼̘ or byte order marks -4. No nicknames designed to annoy other users +We also reserve the right to enforce compliance of hateful or otherwise inappropriate usernames and profiles regardless of the server-specific nickname or profile. +​ -Staff reserves the right to change the nickname of any user for any reason. Failure to comply with these requirements may result in you losing the right to change your nickname. We also reserve the right to discipline users with offensive usernames, regardless of the nickname they're using. +1. No blank or "invisible" names. +2. No slurs or other offensive sentiments or imagery. +3. No noisy unicode characters (for example z̯̯͡a̧͎̺̻̝͕̠l̡͓̫̣g̹̲o̡̼̘) or rapidly flashing avatars. # Infractions -- cgit v1.2.3 From 415b3c0827eb9e0346f6c156d68cff9ddbe55a3a Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 23 Dec 2021 17:33:30 -0500 Subject: Edit "can only Helpers help?" answer and question Remove screenshot of occupied help channels because it doesn't serve much purpose, and help can also be provided in topical channels. --- .../resources/guides/pydis-guides/help-channel-guide.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md index 5f41f442..7c537a13 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md @@ -29,8 +29,8 @@ Each channel on the server has a channel description which briefly describes the # General Help Channels -Our general help channels cycle quickly, with the advantage of attracting a more diverse spectrum of helpers, and getting individual attention and help for your question. These channels are a great choice for generic Python questions, but they can be used for domain-specific Python help as well. -Be sure to [ask good questions](../asking-good-questions) in order to give yourself the best chances of getting help. +Our general help channels cycle quickly, with the advantage of attracting a more diverse spectrum of helpers, and getting individual attention and help for your question. These channels are a great choice for generic Python questions, but they can be used for domain-specific Python help as well. +Be sure to [ask good questions](../asking-good-questions) in order to give yourself the best chances of getting help. ## How To Claim a Channel @@ -64,13 +64,11 @@ If you still feel like your question is being overlooked, read our guide on [ask Go ahead and move your channel to the dormant category by running the `!closed` command. You will only be able to run this command in your own help channel, and likewise no one will be able to prematurely close your claimed help channel (with the exception of staff). -## Q: Are only Helpers supposed to answer questions? +## Q: Can only Helpers answer help questions? -Absolutely not. We strongly encourage all members of the community to help answer questions. If you'd like to help answer some questions, simply head over to one of the help channels that are currently in use. These can be found in the **Python Help: Occupied** category. +Definitely not! We encourage all members of the community to participate in giving help. If you'd like to help answer some questions, head over to the **Occupied Help Channels** or **Topical Chat/Help** categories. -![Occupied help channels](/static/images/content/help_channels/occupied_channels.png) - -Anyone can type in these channels, and users who are particularly helpful [may be offered a chance to join the staff on Python Discord](/pages/server-info/roles/#note-regarding-staff-roles). +Before jumping in, please read our guide on [helping others](../helping-others) which explains our expectations for the culture and quailty of help that we aim for on the server. ## Q: I lost my help channel! -- cgit v1.2.3 From fe9011e32c6bedb222858dca510a10c3cf1e1dac Mon Sep 17 00:00:00 2001 From: minalike Date: Mon, 31 Jan 2022 00:17:30 -0500 Subject: Update content Update some screenshot images to reflect emoji used in category names Break up some of the FAQ questions into smaller questions Mention the !helpdm on command added in 2021 Mention the channel claimed embed added in 2022 Culling of content to focus on information pertaining to getting help --- .../guides/pydis-guides/help-channel-guide.md | 64 +++++++++++++-------- .../content/help_channels/available_message.png | Bin 36279 -> 89386 bytes .../content/help_channels/claimed_channel.png | Bin 0 -> 26100 bytes .../content/help_channels/dormant_channels.png | Bin 6975 -> 22386 bytes .../content/help_channels/topical_channels.png | Bin 29231 -> 66126 bytes 5 files changed, 39 insertions(+), 25 deletions(-) create mode 100644 pydis_site/static/images/content/help_channels/claimed_channel.png (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md index 7c537a13..98b4705d 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md @@ -10,14 +10,13 @@ relevant_links: On the 5th of April 2020, we introduced a new help channel system at Python Discord. This article is a supplementary guide to explain precisely where to go to find help. -We have two different kinds of help channels in our community - **Topical help channels**, and **general help channels**. -Where you should go depends on what you need help with. -These channels also attract different helpers, and move at different speeds, which affects the kind of help you're likely to receive, and how fast you get that help. +We have two different kinds of help channels in our community: **topical help channels** and **general help channels**. +The topical channels can be great for longer and broader discussions, whereas a general help channel might be more appropriate for specific troubleshooting. Individual topical channels may have a narrower audience than the general help channels, but that may also be to your advantage if the audience is very familiar with the area of Python you are struggling with. # Topical Help Channels The topical help channels move at a slower pace than the general help channels. -They also sometimes attract domain experts - for example, `#async-and-concurrency` has CPython contributors who helped write asyncio, and in `#game-development` you can find the creators and maintainers of several game frameworks. +They also sometimes attract domain experts. For example, `#async-and-concurrency` has CPython contributors who helped write asyncio, and in `#game-development` you can find the creators and maintainers of several game frameworks. If your question fits into the domain of one of our topical help channels, and if you're not in a big hurry, then this is probably the best place to ask for help. ![List of topical help channels](/static/images/content/help_channels/topical_channels.png) @@ -29,51 +28,66 @@ Each channel on the server has a channel description which briefly describes the # General Help Channels -Our general help channels cycle quickly, with the advantage of attracting a more diverse spectrum of helpers, and getting individual attention and help for your question. These channels are a great choice for generic Python questions, but they can be used for domain-specific Python help as well. -Be sure to [ask good questions](../asking-good-questions) in order to give yourself the best chances of getting help. +Our general help channels cycle quickly, with the advantage of attracting a more diverse spectrum of helpers, and getting individual focus and attention on your question. These channels are a great choice for generic Python help, but can be used for domain-specific Python help as well. -## How To Claim a Channel +## How to Claim a Channel -There are always 3 available help channels waiting to be claimed in the **Python Help: Available** category. +There are always three help channels waiting to be claimed in the **Available Help Channels** category. ![Available help channels](/static/images/content/help_channels/available_channels.png) +*The Available Help Channels category is always at the top of the server's channel list.* -This message is posted when a channel becomes available for use: -![Channel available message](/static/images/content/help_channels/available_message.png) +![Available message](/static/images/content/help_channels/available_message.png) +*This message indicates that a channel is available.* -In order to claim one, simply start typing your question into one of these channels. Once your question is posted, you have claimed this channel it will be moved down to the **Occupied Help Channels** category. +In order to claim one, simply ask your question into one of the available channels. Be sure to [ask good questions](../asking-good-questions) in order to give yourself the best chances of getting help! -At this point you will have the **Help Cooldown** role which will remain on your profile until your help channel is closed (with the `!closed` command) or goes dormant due to inactivity. +![Channel claimed embed](/static/images/content/help_channels/claimed_channel.png) +*This messages indicates that you've claimed the channel.* -# Frequently Asked Questions +At this point you will have the **Help Cooldown** role which will remain on your profile until your help channel is closed (with the `!close` command) or goes dormant due to inactivity. This ensures that users can claim only one help channel at any given time, giving everyone a chance to have their question seen. -## Q: How long does my help channel stay active? +## Frequently Asked Questions -The channel remains open for **30 minutes** after your last message, but if another user posts in your channel, the closing will be delayed by 10 minutes. +### Q: How long does my help channel stay active? + +The channel remains open for **30 minutes** after your last message, or 10 minutes after the last message sent by another user (whichever time comes later). -*You'll see this message in your channel once it goes dormant:* ![Channel dormant message](/static/images/content/help_channels/dormant_message.png) +*You'll see this message in your channel once it goes dormant.* +### Q: No one answered my question. How come? -## Q: No one answered my question. How come? +The server has users active all over the world and all hours of the day, but some time periods are less active than others. It's also possible that the users that read your question didn't have the knowledge required to help you. If no one responded, feel free to claim another help channel a little later. -The server has users active all over the world and all hours of the day, but some days are less active than others. If no one answered your question, feel free to claim another help channel a little later. +If you feel like your question is continuously being overlooked, read our guide on [asking good questions](../asking-good-questions) to incease your chances of getting a response. -If you still feel like your question is being overlooked, read our guide on [asking good questions](../asking-good-questions) to incease your chances of getting a response. +### Q: My question was answered. What do I do? -## Q: I don't need my help channel anymore, my question was answered. What do I do? +Go ahead use the `!close` command if you've satisfactorily solved your problem. You will only be able to run this command in your own help channel, and no one (outside of staff) will be able to close your channel for you. -Go ahead and move your channel to the dormant category by running the `!closed` command. You will only be able to run this command in your own help channel, and likewise no one will be able to prematurely close your claimed help channel (with the exception of staff). +Closing your help channel once you are finished leads to less occupied channels, which means more attention can be given to other users that still need help. -## Q: Can only Helpers answer help questions? +### Q: Can only Helpers answer help questions? Definitely not! We encourage all members of the community to participate in giving help. If you'd like to help answer some questions, head over to the **Occupied Help Channels** or **Topical Chat/Help** categories. Before jumping in, please read our guide on [helping others](../helping-others) which explains our expectations for the culture and quailty of help that we aim for on the server. -## Q: I lost my help channel! +Tip: run the `!helpdm on` command in `#bot-commands` to get notified via DM with jumplinks to help channels you're participating in. + +### Q: What are the available, occupied, and dormant categories? + +The three help channels under **Available Help Channels** are free for anyone to claim. Claimed channels are then moved to **Occupied Help Channels**. Once they close, they are moved to the **Python Help: Dormant** category until they are needed again for **Available Help Channels**. + +### Q: Can I save my help session for future reference? + +Yes! Because the help channels are continuously cycled in and out without being deleted, this means you can always refer to a previous help session if you found one particularly helpful. + +Tip: reply to a message and run the `.bm` command to get bookmarks sent to you via DM for future reference. + +### Q: I lost my help channel! -No need to panic. -Your channel was probably just marked as dormant. +No need to panic. Your channel was probably just closed due to inactivity. All the dormant help channels are still available at the bottom of the channel list, in the **Python Help: Dormant** category, and also through search. If you're not sure what the name of your help channel was, you can easily find it by using the Discord Search feature. Try searching for `from:` to find the last messages sent by yourself, and from there you will be able to jump directly into the channel by pressing the Jump button on your message. diff --git a/pydis_site/static/images/content/help_channels/available_message.png b/pydis_site/static/images/content/help_channels/available_message.png index 05f6ec7d..09668c9b 100644 Binary files a/pydis_site/static/images/content/help_channels/available_message.png and b/pydis_site/static/images/content/help_channels/available_message.png differ diff --git a/pydis_site/static/images/content/help_channels/claimed_channel.png b/pydis_site/static/images/content/help_channels/claimed_channel.png new file mode 100644 index 00000000..777e31ea Binary files /dev/null and b/pydis_site/static/images/content/help_channels/claimed_channel.png differ diff --git a/pydis_site/static/images/content/help_channels/dormant_channels.png b/pydis_site/static/images/content/help_channels/dormant_channels.png index 2c53de87..7c9ba61e 100644 Binary files a/pydis_site/static/images/content/help_channels/dormant_channels.png and b/pydis_site/static/images/content/help_channels/dormant_channels.png differ diff --git a/pydis_site/static/images/content/help_channels/topical_channels.png b/pydis_site/static/images/content/help_channels/topical_channels.png index 63b48e7b..43530cbe 100644 Binary files a/pydis_site/static/images/content/help_channels/topical_channels.png and b/pydis_site/static/images/content/help_channels/topical_channels.png differ -- cgit v1.2.3 From 64723d375280365cb14ff6fe890a9627a9697bb5 Mon Sep 17 00:00:00 2001 From: minalike Date: Sun, 20 Feb 2022 17:52:46 -0500 Subject: Add table of content and final edits --- .../guides/pydis-guides/help-channel-guide.md | 34 ++++++++++------------ 1 file changed, 16 insertions(+), 18 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md index 98b4705d..010335fd 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md @@ -6,17 +6,15 @@ relevant_links: Asking Good Questions: ../asking-good-questions Role Guide: /pages/server-info/roles Helping Others: ../helping-others +toc: 3 --- -On the 5th of April 2020, we introduced a new help channel system at Python Discord. This article is a supplementary guide to explain precisely where to go to find help. - -We have two different kinds of help channels in our community: **topical help channels** and **general help channels**. -The topical channels can be great for longer and broader discussions, whereas a general help channel might be more appropriate for specific troubleshooting. Individual topical channels may have a narrower audience than the general help channels, but that may also be to your advantage if the audience is very familiar with the area of Python you are struggling with. +At Python Discord we have two different kinds of help channels: **topical help channels** and **general help channels**. # Topical Help Channels -The topical help channels move at a slower pace than the general help channels. -They also sometimes attract domain experts. For example, `#async-and-concurrency` has CPython contributors who helped write asyncio, and in `#game-development` you can find the creators and maintainers of several game frameworks. +In topical channels, users can ask for help regarding specific domains or areas of Python. +These channels also sometimes attract domain experts. For example, `#async-and-concurrency` has CPython contributors who helped write asyncio, and in `#game-development` you can find the creators and maintainers of several game frameworks. If your question fits into the domain of one of our topical help channels, and if you're not in a big hurry, then this is probably the best place to ask for help. ![List of topical help channels](/static/images/content/help_channels/topical_channels.png) @@ -28,7 +26,7 @@ Each channel on the server has a channel description which briefly describes the # General Help Channels -Our general help channels cycle quickly, with the advantage of attracting a more diverse spectrum of helpers, and getting individual focus and attention on your question. These channels are a great choice for generic Python help, but can be used for domain-specific Python help as well. +General help channels can be used for all Python-related help, and have the advantage of attracting a more diverse spectrum of helpers, and getting individual focus and attention on your question. These channels are a great choice for generic Python help, but can be used for domain-specific Python help as well. ## How to Claim a Channel @@ -40,34 +38,34 @@ There are always three help channels waiting to be claimed in the **Available He ![Available message](/static/images/content/help_channels/available_message.png) *This message indicates that a channel is available.* -In order to claim one, simply ask your question into one of the available channels. Be sure to [ask good questions](../asking-good-questions) in order to give yourself the best chances of getting help! +In order to claim one, simply ask your question in one of the available channels. Be sure to [ask questions with enough information](../asking-good-questions) in order to give yourself the best chances of getting help! ![Channel claimed embed](/static/images/content/help_channels/claimed_channel.png) *This messages indicates that you've claimed the channel.* -At this point you will have the **Help Cooldown** role which will remain on your profile until your help channel is closed (with the `!close` command) or goes dormant due to inactivity. This ensures that users can claim only one help channel at any given time, giving everyone a chance to have their question seen. +At this point you will have the **Help Cooldown** role which will remain on your profile until you close your help channel. This ensures that users can claim only one help channel at any given time, giving everyone a chance to have their question seen. -## Frequently Asked Questions +# Frequently Asked Questions -### Q: How long does my help channel stay active? +### How long does my help channel stay active? The channel remains open for **30 minutes** after your last message, or 10 minutes after the last message sent by another user (whichever time comes later). ![Channel dormant message](/static/images/content/help_channels/dormant_message.png) *You'll see this message in your channel once it goes dormant.* -### Q: No one answered my question. How come? +### No one answered my question. How come? -The server has users active all over the world and all hours of the day, but some time periods are less active than others. It's also possible that the users that read your question didn't have the knowledge required to help you. If no one responded, feel free to claim another help channel a little later. +The server has users active all over the world and all hours of the day, but some time periods are less active than others. It's also possible that the users that read your question didn't have the knowledge required to help you. If no one responded, feel free to claim another help channel a little later, or try an appropriate topical channel. If you feel like your question is continuously being overlooked, read our guide on [asking good questions](../asking-good-questions) to incease your chances of getting a response. -### Q: My question was answered. What do I do? +### My question was answered. What do I do? Go ahead use the `!close` command if you've satisfactorily solved your problem. You will only be able to run this command in your own help channel, and no one (outside of staff) will be able to close your channel for you. Closing your help channel once you are finished leads to less occupied channels, which means more attention can be given to other users that still need help. -### Q: Can only Helpers answer help questions? +### Can only Helpers answer help questions? Definitely not! We encourage all members of the community to participate in giving help. If you'd like to help answer some questions, head over to the **Occupied Help Channels** or **Topical Chat/Help** categories. @@ -75,17 +73,17 @@ Before jumping in, please read our guide on [helping others](../helping-others) Tip: run the `!helpdm on` command in `#bot-commands` to get notified via DM with jumplinks to help channels you're participating in. -### Q: What are the available, occupied, and dormant categories? +### What are the available, occupied, and dormant categories? The three help channels under **Available Help Channels** are free for anyone to claim. Claimed channels are then moved to **Occupied Help Channels**. Once they close, they are moved to the **Python Help: Dormant** category until they are needed again for **Available Help Channels**. -### Q: Can I save my help session for future reference? +### Can I save my help session for future reference? Yes! Because the help channels are continuously cycled in and out without being deleted, this means you can always refer to a previous help session if you found one particularly helpful. Tip: reply to a message and run the `.bm` command to get bookmarks sent to you via DM for future reference. -### Q: I lost my help channel! +### I lost my help channel! No need to panic. Your channel was probably just closed due to inactivity. All the dormant help channels are still available at the bottom of the channel list, in the **Python Help: Dormant** category, and also through search. -- cgit v1.2.3 From 16a9727d414ed931cc74fa49f86a52f288399fa1 Mon Sep 17 00:00:00 2001 From: mina Date: Mon, 21 Feb 2022 15:59:45 -0500 Subject: Fixup: final wording and typo --- .../apps/content/resources/guides/pydis-guides/help-channel-guide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md index 010335fd..2be845d3 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/help-channel-guide.md @@ -26,7 +26,7 @@ Each channel on the server has a channel description which briefly describes the # General Help Channels -General help channels can be used for all Python-related help, and have the advantage of attracting a more diverse spectrum of helpers, and getting individual focus and attention on your question. These channels are a great choice for generic Python help, but can be used for domain-specific Python help as well. +General help channels can be used for all Python-related help, and have the advantage of attracting a more diverse spectrum of helpers. There is also the added benefit of receiving individual focus and attention on your question. These channels are a great choice for generic Python help, but can be used for domain-specific Python help as well. ## How to Claim a Channel @@ -57,7 +57,7 @@ The channel remains open for **30 minutes** after your last message, or 10 minut The server has users active all over the world and all hours of the day, but some time periods are less active than others. It's also possible that the users that read your question didn't have the knowledge required to help you. If no one responded, feel free to claim another help channel a little later, or try an appropriate topical channel. -If you feel like your question is continuously being overlooked, read our guide on [asking good questions](../asking-good-questions) to incease your chances of getting a response. +If you feel like your question is continuously being overlooked, read our guide on [asking good questions](../asking-good-questions) to increase your chances of getting a response. ### My question was answered. What do I do? -- cgit v1.2.3 From 26e4f518c874cafdee594c08c01d610e88528dc7 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 20 Feb 2022 17:43:54 +0100 Subject: Prevent race condition with duplicate infractions DRF's `UniqueTogetherValidator` validates uniqueness by querying the database before running the actual insert. This is not, has not, and will never be valid, unless you happen to run a single worker, on a single thread, and your single worker running on a single thread is the only client for the database, in which case it may be valid. For any other cases, it's invalid, and it has never been valid. PostgreSQL spits out an `IntegrityError` for us if we have a duplicate entry, and PostgreSQL is the only valid and correct thing to trust here. The `UniqueTogetherValidator` is removed, and an existing test case calling into this validator to check for uniqueness is removed. Furthermore, to work around a Django quirk, `transaction.atomic()` is added to prevent one `subTest` from messing with another. Closes #665. --- pydis_site/apps/api/serializers.py | 7 --- pydis_site/apps/api/tests/test_infractions.py | 77 +++++++++++--------------- pydis_site/apps/api/viewsets/bot/infraction.py | 18 ++++++ 3 files changed, 50 insertions(+), 52 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 4a702d61..745aff42 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -156,13 +156,6 @@ class InfractionSerializer(ModelSerializer): 'hidden', 'dm_sent' ) - validators = [ - UniqueTogetherValidator( - queryset=Infraction.objects.filter(active=True), - fields=['user', 'type', 'active'], - message='This user already has an active infraction of this type.', - ) - ] def validate(self, attrs: dict) -> dict: """Validate data constraints for the given data and abort if it is invalid.""" diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index b3dd16ee..aa0604f6 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -3,6 +3,7 @@ from datetime import datetime as dt, timedelta, timezone from unittest.mock import patch from urllib.parse import quote +from django.db import transaction from django.db.utils import IntegrityError from django.urls import reverse @@ -492,6 +493,7 @@ class CreationTests(AuthenticatedAPITestCase): ) for infraction_type, hidden in restricted_types: + # https://stackoverflow.com/a/23326971 with self.subTest(infraction_type=infraction_type): invalid_infraction = { 'user': self.user.id, @@ -516,37 +518,38 @@ class CreationTests(AuthenticatedAPITestCase): for infraction_type in active_infraction_types: with self.subTest(infraction_type=infraction_type): - first_active_infraction = { - 'user': self.user.id, - 'actor': self.user.id, - 'type': infraction_type, - 'reason': 'Take me on!', - 'active': True, - 'expires_at': '2019-10-04T12:52:00+00:00' - } - - # Post the first active infraction of a type and confirm it's accepted. - first_response = self.client.post(url, data=first_active_infraction) - self.assertEqual(first_response.status_code, 201) + with transaction.atomic(): + first_active_infraction = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': infraction_type, + 'reason': 'Take me on!', + 'active': True, + 'expires_at': '2019-10-04T12:52:00+00:00' + } - second_active_infraction = { - 'user': self.user.id, - 'actor': self.user.id, - 'type': infraction_type, - 'reason': 'Take on me!', - 'active': True, - 'expires_at': '2019-10-04T12:52:00+00:00' - } - second_response = self.client.post(url, data=second_active_infraction) - self.assertEqual(second_response.status_code, 400) - self.assertEqual( - second_response.json(), - { - 'non_field_errors': [ - 'This user already has an active infraction of this type.' - ] + # Post the first active infraction of a type and confirm it's accepted. + first_response = self.client.post(url, data=first_active_infraction) + self.assertEqual(first_response.status_code, 201) + + second_active_infraction = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': infraction_type, + 'reason': 'Take on me!', + 'active': True, + 'expires_at': '2019-10-04T12:52:00+00:00' } - ) + second_response = self.client.post(url, data=second_active_infraction) + self.assertEqual(second_response.status_code, 400) + self.assertEqual( + second_response.json(), + { + 'non_field_errors': [ + 'This user already has an active infraction of this type.' + ] + } + ) def test_returns_201_for_second_active_infraction_of_different_type(self): """Test if the API accepts a second active infraction of a different type than the first.""" @@ -811,22 +814,6 @@ class SerializerTests(AuthenticatedAPITestCase): self.assertTrue(serializer.is_valid(), msg=serializer.errors) - def test_validation_error_if_active_duplicate(self): - self.create_infraction('ban', active=True) - instance = self.create_infraction('ban', active=False) - - data = {'active': True} - serializer = InfractionSerializer(instance, data=data, partial=True) - - if not serializer.is_valid(): - self.assertIn('non_field_errors', serializer.errors) - - code = serializer.errors['non_field_errors'][0].code - msg = f'Expected failure on unique validator but got {serializer.errors}' - self.assertEqual(code, 'unique', msg=msg) - else: # pragma: no cover - self.fail('Validation unexpectedly succeeded.') - def test_is_valid_for_new_active_infraction(self): self.create_infraction('ban', active=False) diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index 8a48ed1f..31e8ba40 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -1,5 +1,6 @@ from datetime import datetime +from django.db import IntegrityError from django.db.models import QuerySet from django.http.request import HttpRequest from django_filters.rest_framework import DjangoFilterBackend @@ -271,3 +272,20 @@ class InfractionViewSet( """ self.serializer_class = ExpandedInfractionSerializer return self.partial_update(*args, **kwargs) + + def create(self, request: HttpRequest, *args, **kwargs) -> Response: + """ + Create an infraction for a target user. + + Called by the Django Rest Framework in response to the corresponding HTTP request. + """ + try: + return super().create(request, *args, **kwargs) + except IntegrityError: + raise ValidationError( + { + 'non_field_errors': [ + 'This user already has an active infraction of this type.', + ] + } + ) -- cgit v1.2.3 From c7f4d04acbdf4ac25eaa12009ce54fdd7e23591c Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 21 Feb 2022 23:12:22 +0100 Subject: Assert we're dealing with the unique constraint --- pydis_site/apps/api/viewsets/bot/infraction.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index 31e8ba40..7e7adbca 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -281,11 +281,19 @@ class InfractionViewSet( """ try: return super().create(request, *args, **kwargs) - except IntegrityError: - raise ValidationError( - { - 'non_field_errors': [ - 'This user already has an active infraction of this type.', - ] - } - ) + except IntegrityError as err: + # We need to use `__cause__` here, as Django reraises the internal + # UniqueViolation emitted by psycopg2 (which contains the attribute + # that we actually need) + # + # _meta is documented and mainly named that way to prevent + # name clashes: https://docs.djangoproject.com/en/dev/ref/models/meta/ + if err.__cause__.diag.constraint_name == Infraction._meta.constraints[0].name: + raise ValidationError( + { + 'non_field_errors': [ + 'This user already has an active infraction of this type.', + ] + } + ) + raise # pragma: no cover - no other constraint to test with -- cgit v1.2.3 From 01ccc1dac80cc2958849d5be90255294f38878fb Mon Sep 17 00:00:00 2001 From: mbaruh Date: Tue, 22 Feb 2022 20:44:24 +0200 Subject: Changed channeld fields to char arrays, merged migrations - The fields concerning channels were changed to contains strings instead of integers in order to allow specifying channels and categories by name. The migrations were merged into a single migration. --- .../apps/api/migrations/0070_new_filter_schema.py | 145 ------------------- .../api/migrations/0074_merge_20211017_0822.py | 14 -- ..._filter_and_filterlist_for_new_filter_schema.py | 95 ------------- .../api/migrations/0078_merge_20211218_2200.py | 14 -- .../migrations/0079_dm_embed_and_alert_fields.py | 58 -------- .../apps/api/migrations/0079_new_filter_schema.py | 156 +++++++++++++++++++++ 6 files changed, 156 insertions(+), 326 deletions(-) delete mode 100644 pydis_site/apps/api/migrations/0070_new_filter_schema.py delete mode 100644 pydis_site/apps/api/migrations/0074_merge_20211017_0822.py delete mode 100644 pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py delete mode 100644 pydis_site/apps/api/migrations/0078_merge_20211218_2200.py delete mode 100644 pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py create mode 100644 pydis_site/apps/api/migrations/0079_new_filter_schema.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0070_new_filter_schema.py b/pydis_site/apps/api/migrations/0070_new_filter_schema.py deleted file mode 100644 index f33c112b..00000000 --- a/pydis_site/apps/api/migrations/0070_new_filter_schema.py +++ /dev/null @@ -1,145 +0,0 @@ -# Modified migration file to migrate existing filters to the new one -from datetime import timedelta - -import django.contrib.postgres.fields -from django.apps.registry import Apps -from django.db import migrations, models -import django.db.models.deletion -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -import pydis_site.apps.api.models.bot.filters - -OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY')) - - -def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: - filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter") - filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList") - filter_list_old = apps.get_model("api", "FilterListOld") - - for name, type_ in OLD_LIST_NAMES: - objects = filter_list_old.objects.filter(type=name) - if name == "DOMAIN_NAME": - dm_content = "Your URL has been removed because it matched a blacklisted domain: {match}" - elif name == "GUILD_INVITE": - dm_content = "Per Rule 6, your invite link has been removed. " \ - "Our server rules can be found here: https://pythondiscord.com/pages/rules" - else: - dm_content = "" - - list_ = filter_list.objects.create( - name=name.lower(), - list_type=1 if type_ == "ALLOW" else 0, - ping_type=(["onduty"] if name != "FILE_FORMAT" else []), - filter_dm=True, - dm_ping_type=[], - delete_messages=(True if name != "FILTER_TOKEN" else False), - bypass_roles=["staff"], - enabled=True, - dm_content=dm_content, - infraction_type="", - infraction_reason="", - infraction_duration=timedelta(seconds=0), - disallowed_channels=[], - disallowed_categories=[], - allowed_channels=[], - allowed_categories=[] - ) - - for object_ in objects: - new_object = filter_.objects.create( - content=object_.content, - filter_list=list_, - description=object_.comment, - additional_field=None, - ping_type=None, - filter_dm=None, - dm_ping_type=None, - delete_messages=None, - bypass_roles=None, - enabled=None, - dm_content=None, - infraction_type=None, - infraction_reason=None, - infraction_duration=None, - disallowed_channels=None, - disallowed_categories=None, - allowed_channels=None, - allowed_categories=None - ) - new_object.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0069_documentationlink_validators'), - ] - - operations = [ - migrations.RenameModel( - old_name='FilterList', - new_name='FilterListOld' - ), - migrations.CreateModel( - name='Filter', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), - ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)), - ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)), - ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), - ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), - ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), - ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)), - ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field], null=True)), - ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)), - ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), - ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), - ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True)), - ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), - ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), - ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), - ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), - ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), null=True, size=None)), - ], - ), - migrations.CreateModel( - name='FilterList', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), - ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')), - ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), - ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')), - ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=20), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), - ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')), - ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field])), - ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')), - ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), - ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), - ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), - ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), - ('disallowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('disallowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('allowed_channels', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ('allowed_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None)), - ], - ), - migrations.AddField( - model_name='filter', - name='filter_list', - field=models.ForeignKey(help_text='The filter list containing this filter.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='api.FilterList'), - ), - migrations.AddConstraint( - model_name='filterlist', - constraint=models.UniqueConstraint(fields=('name', 'list_type'), name='unique_name_type'), - ), - migrations.RunPython( - code=forward, # Core of the migration - reverse_code=lambda *_: None - ), - migrations.DeleteModel( - name='FilterListOld' - ) - ] diff --git a/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py b/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py deleted file mode 100644 index ae41ac71..00000000 --- a/pydis_site/apps/api/migrations/0074_merge_20211017_0822.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 3.0.14 on 2021-10-17 08:22 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0073_otn_allow_GT_and_LT'), - ('api', '0070_new_filter_schema'), - ] - - operations = [ - ] diff --git a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py b/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py deleted file mode 100644 index 1e24b379..00000000 --- a/pydis_site/apps/api/migrations/0075_prepare_filter_and_filterlist_for_new_filter_schema.py +++ /dev/null @@ -1,95 +0,0 @@ -# Generated by Django 3.0.14 on 2021-12-11 23:14 -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - - -def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: - FilterList = apps.get_model("api", "FilterList") - change_map = { - "filter_token": "token", - "domain_name": "domain", - "guild_invite": "invite", - "file_format": "extension" - } - for filter_list in FilterList.objects.all(): - if change_map.get(filter_list.name): - filter_list.name = change_map.get(filter_list.name) - filter_list.save() - redirects = FilterList( - name="redirect", - ping_type=[], - dm_ping_type=[], - enabled_channels=[], - disabled_channels=[], - disabled_categories=[], - list_type=0, - filter_dm=True, - delete_messages=False, - bypass_roles=["staff"], - enabled=True - ) - redirects.save() - - -def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: - FilterList = apps.get_model("api", "FilterList") - change_map = { - "token": "filter_token", - "domain": "domain_name", - "invite": "guild_invite", - "format": "file_format" - } - for filter_list in FilterList.objects.all(): - if change_map.get(filter_list.name): - filter_list.name = change_map.get(filter_list.name) - filter_list.save() - FilterList.objects.filter(name="redirect").delete() - - -class Migration(migrations.Migration): - dependencies = [ - ('api', '0074_merge_20211017_0822'), - ] - - operations = [ - migrations.RenameField( - model_name='filter', - old_name='allowed_categories', - new_name='disabled_categories', - ), - migrations.RenameField( - model_name='filter', - old_name='allowed_channels', - new_name='disabled_channels', - ), - migrations.RenameField( - model_name='filter', - old_name='disallowed_channels', - new_name='enabled_channels', - ), - migrations.RenameField( - model_name='filterlist', - old_name='allowed_categories', - new_name='disabled_categories', - ), - migrations.RenameField( - model_name='filterlist', - old_name='allowed_channels', - new_name='disabled_channels', - ), - migrations.RenameField( - model_name='filterlist', - old_name='disallowed_channels', - new_name='enabled_channels', - ), - migrations.RemoveField( - model_name='filterlist', - name='disallowed_categories', - ), - migrations.RemoveField( - model_name='filter', - name='disallowed_categories', - ), - migrations.RunPython(migrate_filterlist, unmigrate_filterlist) - ] diff --git a/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py b/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py deleted file mode 100644 index 7fe559f5..00000000 --- a/pydis_site/apps/api/migrations/0078_merge_20211218_2200.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 3.1.14 on 2021-12-18 22:00 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0075_prepare_filter_and_filterlist_for_new_filter_schema'), - ('api', '0077_use_generic_jsonfield'), - ] - - operations = [ - ] diff --git a/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py b/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py deleted file mode 100644 index cae175df..00000000 --- a/pydis_site/apps/api/migrations/0079_dm_embed_and_alert_fields.py +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by Django 3.1.14 on 2021-12-19 23:05 -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - - -def migrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: - FilterList = apps.get_model("api", "FilterList") - change_map = { - "token": True, - "domain": True, - "invite": True, - "extension": False, - "redirect": False - } - for filter_list in FilterList.objects.all(): - filter_list.send_alert = change_map.get(filter_list.name) - filter_list.dm_embed = "" - filter_list.save() - - -def unmigrate_filterlist(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: - FilterList = apps.get_model("api", "FilterList") - for filter_list in FilterList.objects.all(): - filter_list.send_alert = True - filter_list.server_message_embed = None - filter_list.save() - - -class Migration(migrations.Migration): - dependencies = [ - ('api', '0078_merge_20211213_0552'), - ('api', '0078_merge_20211218_2200'), - ] - - operations = [ - migrations.AddField( - model_name='filter', - name='send_alert', - field=models.BooleanField(help_text='Whether alert should be sent.', null=True), - ), - migrations.AddField( - model_name='filter', - name='dm_embed', - field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True), - ), - migrations.AddField( - model_name='filterlist', - name='send_alert', - field=models.BooleanField(default=True, help_text='Whether alert should be sent.'), - ), - migrations.AddField( - model_name='filterlist', - name='dm_embed', - field=models.CharField(help_text='The content of the DM embed', max_length=2000, null=True), - ), - migrations.RunPython(migrate_filterlist, unmigrate_filterlist) - ] diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py new file mode 100644 index 00000000..94494186 --- /dev/null +++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py @@ -0,0 +1,156 @@ +# Modified migration file to migrate existing filters to the new one +from datetime import timedelta + +import django.contrib.postgres.fields +from django.apps.registry import Apps +from django.db import migrations, models +import django.db.models.deletion +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import pydis_site.apps.api.models.bot.filters + +OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY'), ('REDIRECT', 'DENY')) +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) + if name == "DOMAIN_NAME": + dm_content = "Your URL has been removed because it matched a blacklisted domain: {match}" + elif name == "GUILD_INVITE": + dm_content = "Per Rule 6, your invite link has been removed. " \ + "Our server rules can be found here: https://pythondiscord.com/pages/rules" + else: + dm_content = "" + + list_ = filter_list.objects.create( + name=change_map[name], + list_type=1 if type_ == "ALLOW" else 0, + ping_type=(["Moderators"] if name != "FILE_FORMAT" else []), + filter_dm=True, + dm_ping_type=[], + delete_messages=(True if name != "FILTER_TOKEN" else False), + bypass_roles=["Helpers"], + enabled=True, + dm_content=dm_content, + dm_embed="", + infraction_type="", + infraction_reason="", + infraction_duration=timedelta(seconds=0), + disabled_channels=[], + disabled_categories=(["CODE JAM"] if name in ("FILE_FORMAT", "GUILD_INVITE") else []), + enabled_channels=[], + send_alert=(name in ('GUILD_INVITE', 'DOMAIN_NAME', 'FILTER_TOKEN')) + ) + + for object_ in objects: + new_object = filter_.objects.create( + content=object_.content, + filter_list=list_, + description=object_.comment, + additional_field=None, + ping_type=None, + filter_dm=None, + dm_ping_type=None, + delete_messages=None, + bypass_roles=None, + enabled=None, + dm_content=None, + dm_embed=None, + infraction_type=None, + infraction_reason=None, + infraction_duration=None, + disabled_channels=None, + disabled_categories=None, + enabled_channels=None, + send_alert=None, + ) + new_object.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0078_merge_20211213_0552'), + ] + + operations = [ + migrations.RenameModel( + old_name='FilterList', + new_name='FilterListOld' + ), + migrations.CreateModel( + name='Filter', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), + ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)), + ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)), + ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), + ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), + ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), + ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)), + ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field], null=True)), + ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)), + ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), + ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)), + ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), + ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True)), + ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), + ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter.", null=True, size=None)), + ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", null=True, size=None)), + ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", null=True, size=None)), + ('send_alert', models.BooleanField(help_text='Whether an alert should be sent.', null=True)), + ], + ), + migrations.CreateModel( + name='FilterList', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), + ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')), + ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), + ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')), + ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), + ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')), + ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field])), + ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')), + ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), + ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)), + ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), + ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), + ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), + ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter.", size=None)), + ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", size=None)), + ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", size=None)), + ('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' + ) + ] -- cgit v1.2.3 From 37f9296322d5aaef6aefc68eb97e6e1d5c0df531 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 23 Feb 2022 23:51:05 +0200 Subject: Extensions list is ALLOW, not DENY --- pydis_site/apps/api/migrations/0079_new_filter_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py index 94494186..4728ea91 100644 --- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py @@ -9,7 +9,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor import pydis_site.apps.api.models.bot.filters -OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'DENY'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY'), ('REDIRECT', 'DENY')) +OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'ALLOW'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY'), ('REDIRECT', 'DENY')) change_map = { "FILTER_TOKEN": "token", "DOMAIN_NAME": "domain", -- cgit v1.2.3 From 9d1ea1667d501e515957909d5c261525ff6aeaa6 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 23 Feb 2022 19:36:43 +0100 Subject: Remove the admin app This app is completely unused. I assume it was planned to be a space for customizing the Django admin, but we don't even have it in `INSTALLED_APPS`, nor our URLs. --- pydis_site/apps/admin/__init__.py | 0 pydis_site/apps/admin/urls.py | 8 -------- 2 files changed, 8 deletions(-) delete mode 100644 pydis_site/apps/admin/__init__.py delete mode 100644 pydis_site/apps/admin/urls.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/admin/__init__.py b/pydis_site/apps/admin/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pydis_site/apps/admin/urls.py b/pydis_site/apps/admin/urls.py deleted file mode 100644 index a4f3e517..00000000 --- a/pydis_site/apps/admin/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.contrib import admin -from django.urls import path - - -app_name = 'admin' -urlpatterns = ( - path('', admin.site.urls), -) -- cgit v1.2.3 From a2fcfdf8fd80fc4cfd89be19ffb18a3c1799d2cb Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 24 Feb 2022 21:14:35 +0200 Subject: Create placeholder value for dm embed content in ext list Some value is needed to signal the bot a message should be sent for a blocked extension. The value itself will be changed at runtime, but this allows avoiding the bot code delving into the exact API response format. --- pydis_site/apps/api/migrations/0079_new_filter_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py index 4728ea91..58ed0025 100644 --- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py @@ -44,7 +44,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: bypass_roles=["Helpers"], enabled=True, dm_content=dm_content, - dm_embed="", + dm_embed="" if name != "FILE_FORMAT" else "*Defined at runtime.*", infraction_type="", infraction_reason="", infraction_duration=timedelta(seconds=0), -- cgit v1.2.3 From b0f4b93ee831d0873f134440a6554177cc043feb Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 26 Feb 2022 00:53:28 +0200 Subject: Add invites denylist to the migration --- pydis_site/apps/api/migrations/0079_new_filter_schema.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py index 58ed0025..43915edb 100644 --- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py @@ -9,7 +9,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor import pydis_site.apps.api.models.bot.filters -OLD_LIST_NAMES = (('GUILD_INVITE', 'ALLOW'), ('FILE_FORMAT', 'ALLOW'), ('DOMAIN_NAME', 'DENY'), ('FILTER_TOKEN', 'DENY'), ('REDIRECT', 'DENY')) +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", @@ -25,7 +25,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: filter_list_old = apps.get_model("api", "FilterListOld") for name, type_ in OLD_LIST_NAMES: - objects = filter_list_old.objects.filter(type=name) + objects = filter_list_old.objects.filter(type=name, allowed=type_) if name == "DOMAIN_NAME": dm_content = "Your URL has been removed because it matched a blacklisted domain: {match}" elif name == "GUILD_INVITE": @@ -36,7 +36,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: list_ = filter_list.objects.create( name=change_map[name], - list_type=1 if type_ == "ALLOW" else 0, + list_type=int(type_), ping_type=(["Moderators"] if name != "FILE_FORMAT" else []), filter_dm=True, dm_ping_type=[], -- cgit v1.2.3 From 7f617657068e2cf877edfb06a70d435dd5dfbdc0 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 25 Feb 2022 01:09:58 +0100 Subject: Remove and merge superfluous modules The constants module more or less did what belongs to the settings. --- pydis_site/apps/home/views/home.py | 8 ++++---- pydis_site/constants.py | 6 ------ pydis_site/context_processors.py | 5 ++--- pydis_site/settings.py | 10 +++++++++- 4 files changed, 15 insertions(+), 14 deletions(-) delete mode 100644 pydis_site/constants.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py index e28a3a00..dafe32c0 100644 --- a/pydis_site/apps/home/views/home.py +++ b/pydis_site/apps/home/views/home.py @@ -2,6 +2,7 @@ import logging from typing import Dict, List import requests +from django.conf import settings from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponse from django.shortcuts import render @@ -10,7 +11,6 @@ from django.views import View from pydis_site import settings from pydis_site.apps.home.models import RepositoryMetadata -from pydis_site.constants import GITHUB_TOKEN, TIMEOUT_PERIOD log = logging.getLogger(__name__) @@ -43,8 +43,8 @@ class HomeView(View): # specifically, GitHub will reject any requests from us due to the # invalid header. We can make a limited number of anonymous requests # though, which is useful for testing. - if GITHUB_TOKEN: - self.headers = {"Authorization": f"token {GITHUB_TOKEN}"} + if settings.GITHUB_TOKEN: + self.headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"} else: self.headers = {} @@ -60,7 +60,7 @@ class HomeView(View): api_data: List[dict] = requests.get( self.github_api, headers=self.headers, - timeout=TIMEOUT_PERIOD + timeout=settings.TIMEOUT_PERIOD ).json() except requests.exceptions.Timeout: log.error("Request to fetch GitHub repository metadata for timed out!") diff --git a/pydis_site/constants.py b/pydis_site/constants.py deleted file mode 100644 index e913f40f..00000000 --- a/pydis_site/constants.py +++ /dev/null @@ -1,6 +0,0 @@ -import os - -GIT_SHA = os.environ.get("GIT_SHA", "development") -GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") -# How long to wait for synchronous requests before timing out -TIMEOUT_PERIOD = int(os.environ.get("TIMEOUT_PERIOD", 5)) diff --git a/pydis_site/context_processors.py b/pydis_site/context_processors.py index 6937a3db..0e8b4a94 100644 --- a/pydis_site/context_processors.py +++ b/pydis_site/context_processors.py @@ -1,8 +1,7 @@ +from django.conf import settings from django.template import RequestContext -from pydis_site.constants import GIT_SHA - def git_sha_processor(_: RequestContext) -> dict: """Expose the git SHA for this repo to all views.""" - return {'git_sha': GIT_SHA} + return {'git_sha': settings.GIT_SHA} diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 3b146f2c..4f2b4aef 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -20,15 +20,20 @@ import environ import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration -from pydis_site.constants import GIT_SHA env = environ.Env( DEBUG=(bool, False), SITE_DSN=(str, ""), BUILDING_DOCKER=(bool, False), STATIC_BUILD=(bool, False), + GIT_SHA=(str, 'development'), + TIMEOUT_PERIOD=(int, 5), + GITHUB_TOKEN=(str, None), ) +GIT_SHA = env("GIT_SHA") +GITHUB_TOKEN = env("GITHUB_TOKEN") + sentry_sdk.init( dsn=env('SITE_DSN'), integrations=[DjangoIntegration()], @@ -288,3 +293,6 @@ CONTENT_PAGES_PATH = Path(BASE_DIR, "pydis_site", "apps", "content", "resources" # Path for redirection links REDIRECTIONS_PATH = Path(BASE_DIR, "pydis_site", "apps", "redirect", "redirects.yaml") + +# How long to wait for synchronous requests before timing out +TIMEOUT_PERIOD = env("TIMEOUT_PERIOD") -- cgit v1.2.3 From b78d9496867a13ed096b4b1f561a47a25001a901 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 25 Feb 2022 01:10:45 +0100 Subject: Add a readme to the Django project directory --- pydis_site/README.md | 52 ++++++++++++++++++++++++++++++++++++++ pydis_site/apps/home/views/home.py | 1 - 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 pydis_site/README.md (limited to 'pydis_site/apps') diff --git a/pydis_site/README.md b/pydis_site/README.md new file mode 100644 index 00000000..456f7e9e --- /dev/null +++ b/pydis_site/README.md @@ -0,0 +1,52 @@ +# `pydis_site` project directory + +This directory hosts the root of our **Django project**[^1], and is responsible +for all logic powering our website. Let's go over the directories in detail: + +- [`apps`](./apps) contains our **Django apps**. If you want to add your own API + endpoint or new functionality to our homepage, that's the place to go. + + +- [`static`](./static) contains our **static files**, such as CSS, JavaScript, + images, and anything else that isn't either content or Python code. Static + files relevant for a specific application are put into subdirectories named + after the application. + +- [`templates`](./templates) contains our **Django templates**. Like with static + files, templates specific to a single application are stored in a subdirectory + named after that application. We also have two special templates here: + + - `404.html`, which is our error page shown when a site was not found. + + - `500.html`, which is our error page shown in the astronomically rare case + that we encounter an internal server error. + + +We also have a few files in here that are relevant or useful in large parts of +the website: + +- [`context_processors.py`](./context_processors.py), which contains custom + *context processors* that add variables to the Django template context. To + read more, see the [`RequestContext` documentation from + Django](https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.RequestContext) + +- [`settings.py`](./settings.py), our Django settings file. This mostly just + parses configuration out of your environment variables, so you shouldn't need + to edit it directly unless you want to add new settings. + +- [`urls.py`](./urls.py), which configures our Django URL routing by installing + our apps into the routing tree. + +- [`wsgi.py`](./wsgi.py), which serves as an adapter for + [`gunicorn`](https://github.com/benoitc/gunicorn), + [`uwsgi`](https://github.com/unbit/uwsgi) or other application servers to run + our application in production. Unless you want to test an interaction between + our application and those servers, you probably won't need to touch this. + +Note that for both `static` and `templates`, we are not using the default Django +directory structure which puts these directories in a directory per app (in our +case, this would for example be ``pydis_site/apps/content/static/``). + + +[^1]: See [Django Glossary: project](https://docs.djangoproject.com/en/dev/glossary/#term-project) diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py index dafe32c0..69e706c5 100644 --- a/pydis_site/apps/home/views/home.py +++ b/pydis_site/apps/home/views/home.py @@ -2,7 +2,6 @@ import logging from typing import Dict, List import requests -from django.conf import settings from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponse from django.shortcuts import render -- cgit v1.2.3 From 02ea9e97f68e5388f7c3ade6ec48b11b272018bf Mon Sep 17 00:00:00 2001 From: mbaruh Date: Tue, 1 Mar 2022 23:49:52 +0200 Subject: Refine DM content for domains --- pydis_site/apps/api/migrations/0079_new_filter_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py index 43915edb..89f70799 100644 --- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py @@ -27,7 +27,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: for name, type_ in OLD_LIST_NAMES: objects = filter_list_old.objects.filter(type=name, allowed=type_) if name == "DOMAIN_NAME": - dm_content = "Your URL has been removed because it matched a blacklisted domain: {match}" + 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" -- cgit v1.2.3 From 66061855ca2098fae7e80fa2e1abb29f810495f1 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 26 Feb 2022 14:36:53 +0100 Subject: Fix timezone awareness warnings Add a `warnings.warnings` clause to prevent these from being raised again in the future, and raise a full traceback if they don't. --- pydis_site/apps/api/tests/test_infractions.py | 4 ++-- pydis_site/apps/api/tests/test_models.py | 7 +++---- pydis_site/apps/api/tests/test_reminders.py | 17 +++++++++-------- pydis_site/apps/api/viewsets/bot/infraction.py | 11 +++++------ pydis_site/settings.py | 17 +++++++++++++++++ 5 files changed, 36 insertions(+), 20 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index aa0604f6..f1107734 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -80,7 +80,7 @@ class InfractionTests(AuthenticatedAPITestCase): type='superstar', reason='This one doesn\'t matter anymore.', active=True, - expires_at=datetime.datetime.utcnow() + datetime.timedelta(hours=5) + expires_at=dt.now(timezone.utc) + datetime.timedelta(hours=5) ) cls.voiceban_expires_later = Infraction.objects.create( user_id=cls.user.id, @@ -88,7 +88,7 @@ class InfractionTests(AuthenticatedAPITestCase): type='voice_ban', reason='Jet engine mic', active=True, - expires_at=datetime.datetime.utcnow() + datetime.timedelta(days=5) + expires_at=dt.now(timezone.utc) + datetime.timedelta(days=5) ) def test_list_all(self): diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index 5c9ddea4..0fad467c 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -1,8 +1,7 @@ -from datetime import datetime as dt +from datetime import datetime as dt, timezone from django.core.exceptions import ValidationError from django.test import SimpleTestCase, TestCase -from django.utils import timezone from pydis_site.apps.api.models import ( DeletedMessage, @@ -41,7 +40,7 @@ class NitroMessageLengthTest(TestCase): self.context = MessageDeletionContext.objects.create( id=50, actor=self.user, - creation=dt.utcnow() + creation=dt.now(timezone.utc) ) def test_create(self): @@ -99,7 +98,7 @@ class StringDunderMethodTests(SimpleTestCase): name='shawn', discriminator=555, ), - creation=dt.utcnow() + creation=dt.now(timezone.utc) ), embeds=[] ), diff --git a/pydis_site/apps/api/tests/test_reminders.py b/pydis_site/apps/api/tests/test_reminders.py index 709685bc..e17569f0 100644 --- a/pydis_site/apps/api/tests/test_reminders.py +++ b/pydis_site/apps/api/tests/test_reminders.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from django.forms.models import model_to_dict from django.urls import reverse @@ -91,7 +91,7 @@ class ReminderDeletionTests(AuthenticatedAPITestCase): cls.reminder = Reminder.objects.create( author=cls.author, content="Don't forget to set yourself a reminder", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(timezone.utc), jump_url="https://www.decliningmentalfaculties.com", channel_id=123 ) @@ -122,7 +122,7 @@ class ReminderListTests(AuthenticatedAPITestCase): cls.reminder_one = Reminder.objects.create( author=cls.author, content="We should take Bikini Bottom, and push it somewhere else!", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(timezone.utc), jump_url="https://www.icantseemyforehead.com", channel_id=123 ) @@ -130,16 +130,17 @@ class ReminderListTests(AuthenticatedAPITestCase): cls.reminder_two = Reminder.objects.create( author=cls.author, content="Gahhh-I love being purple!", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(timezone.utc), jump_url="https://www.goofygoobersicecreampartyboat.com", channel_id=123, active=False ) + drf_format = '%Y-%m-%dT%H:%M:%S.%fZ' cls.rem_dict_one = model_to_dict(cls.reminder_one) - cls.rem_dict_one['expiration'] += 'Z' # Massaging a quirk of the response time format + cls.rem_dict_one['expiration'] = cls.rem_dict_one['expiration'].strftime(drf_format) cls.rem_dict_two = model_to_dict(cls.reminder_two) - cls.rem_dict_two['expiration'] += 'Z' # Massaging a quirk of the response time format + cls.rem_dict_two['expiration'] = cls.rem_dict_two['expiration'].strftime(drf_format) def test_reminders_in_full_list(self): url = reverse('api:bot:reminder-list') @@ -175,7 +176,7 @@ class ReminderRetrieveTests(AuthenticatedAPITestCase): cls.reminder = Reminder.objects.create( author=cls.author, content="Reminder content", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(timezone.utc), jump_url="http://example.com/", channel_id=123 ) @@ -203,7 +204,7 @@ class ReminderUpdateTests(AuthenticatedAPITestCase): cls.reminder = Reminder.objects.create( author=cls.author, content="Squash those do-gooders", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(timezone.utc), jump_url="https://www.decliningmentalfaculties.com", channel_id=123 ) diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index 7e7adbca..d82090aa 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -3,6 +3,7 @@ from datetime import datetime from django.db import IntegrityError from django.db.models import QuerySet from django.http.request import HttpRequest +from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from rest_framework.decorators import action from rest_framework.exceptions import ValidationError @@ -184,20 +185,18 @@ class InfractionViewSet( filter_expires_after = self.request.query_params.get('expires_after') if filter_expires_after: try: - additional_filters['expires_at__gte'] = datetime.fromisoformat( - filter_expires_after - ) + expires_after_parsed = datetime.fromisoformat(filter_expires_after) except ValueError: raise ValidationError({'expires_after': ['failed to convert to datetime']}) + additional_filters['expires_at__gte'] = timezone.make_aware(expires_after_parsed) filter_expires_before = self.request.query_params.get('expires_before') if filter_expires_before: try: - additional_filters['expires_at__lte'] = datetime.fromisoformat( - filter_expires_before - ) + expires_before_parsed = datetime.fromisoformat(filter_expires_before) except ValueError: raise ValidationError({'expires_before': ['failed to convert to datetime']}) + additional_filters['expires_at__lte'] = timezone.make_aware(expires_before_parsed) if 'expires_at__lte' in additional_filters and 'expires_at__gte' in additional_filters: if additional_filters['expires_at__gte'] > additional_filters['expires_at__lte']: diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 4f2b4aef..17f220f3 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/2.1/ref/settings/ import os import secrets import sys +import warnings from pathlib import Path from socket import gethostbyname, gethostname @@ -53,10 +54,26 @@ if DEBUG: ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*']) SECRET_KEY = "yellow polkadot bikini" # noqa: S105 + # Prevent verbose warnings emitted when passing a non-timezone aware + # datetime object to the database, whilst we have time zone support + # active. See the Django documentation for more details: + # https://docs.djangoproject.com/en/dev/topics/i18n/timezones/ + warnings.filterwarnings( + 'error', r"DateTimeField .* received a naive datetime", + RuntimeWarning, r'django\.db\.models\.fields', + ) + elif 'CI' in os.environ: ALLOWED_HOSTS = ['*'] SECRET_KEY = secrets.token_urlsafe(32) + # See above. We run with `CI=true`, but debug unset in GitHub Actions, + # so we also want to filter it there. + warnings.filterwarnings( + 'error', r"DateTimeField .* received a naive datetime", + RuntimeWarning, r'django\.db\.models\.fields', + ) + else: ALLOWED_HOSTS = env.list( 'ALLOWED_HOSTS', -- cgit v1.2.3 From b7af4cf75c09983a1fdebcb92c9b98aa86548918 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 26 Feb 2022 14:57:58 +0100 Subject: Capture GitHub response logs --- pydis_site/apps/home/tests/test_repodata_helpers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/home/tests/test_repodata_helpers.py b/pydis_site/apps/home/tests/test_repodata_helpers.py index 5634bc9b..d43bd28e 100644 --- a/pydis_site/apps/home/tests/test_repodata_helpers.py +++ b/pydis_site/apps/home/tests/test_repodata_helpers.py @@ -122,7 +122,10 @@ class TestRepositoryMetadataHelpers(TestCase): """Tests that fallback to the database is performed when we get garbage back.""" mock_get.return_value.json.return_value = ['garbage'] - metadata = self.home_view._get_repo_data() + # Capture logs and ensure the problematic response is logged + with self.assertLogs(): + metadata = self.home_view._get_repo_data() + self.assertEquals(len(metadata), 0) def test_cleans_up_stale_metadata(self): -- cgit v1.2.3 From 2734a68ed35598fcf0615353077a875549db4b0d Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 2 Mar 2022 19:45:25 +0100 Subject: Explicitly pass timezone --- pydis_site/apps/api/viewsets/bot/infraction.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index d82090aa..7f31292f 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -188,7 +188,10 @@ class InfractionViewSet( expires_after_parsed = datetime.fromisoformat(filter_expires_after) except ValueError: raise ValidationError({'expires_after': ['failed to convert to datetime']}) - additional_filters['expires_at__gte'] = timezone.make_aware(expires_after_parsed) + additional_filters['expires_at__gte'] = timezone.make_aware( + expires_after_parsed, + timezone=timezone.utc, + ) filter_expires_before = self.request.query_params.get('expires_before') if filter_expires_before: @@ -196,7 +199,10 @@ class InfractionViewSet( expires_before_parsed = datetime.fromisoformat(filter_expires_before) except ValueError: raise ValidationError({'expires_before': ['failed to convert to datetime']}) - additional_filters['expires_at__lte'] = timezone.make_aware(expires_before_parsed) + additional_filters['expires_at__lte'] = timezone.make_aware( + expires_before_parsed, + timezone=timezone.utc, + ) if 'expires_at__lte' in additional_filters and 'expires_at__gte' in additional_filters: if additional_filters['expires_at__gte'] > additional_filters['expires_at__lte']: -- cgit v1.2.3 From 0d1fb628199cf80c0d12d283b8323905d9419ae9 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Mar 2022 23:53:01 +0100 Subject: Add a README for the API app --- pydis_site/apps/api/README.md | 69 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 pydis_site/apps/api/README.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/README.md b/pydis_site/apps/api/README.md new file mode 100644 index 00000000..837a89fc --- /dev/null +++ b/pydis_site/apps/api/README.md @@ -0,0 +1,69 @@ +# The "api" app + +This application takes care of most of the heavy lifting in the site, that is, +allowing our bot to manipulate and query information stored in the site's +database. + +We make heavy use of [Django REST +Framework](https://www.django-rest-framework.org) here, which builds on top of +Django to allow us to easily build out the +[REST](https://en.wikipedia.org/wiki/Representational_state_transfer) API +consumed by our bot. Working with the API app requires basic knowledge of DRF - +the [quickstart +guide](https://www.django-rest-framework.org/tutorial/quickstart/) is a great +resource to get started. + +## Directory structure + +Let's look over each of the subdirectories here: + +- `migrations` is the standard Django migrations folder. You usually won't need + to edit this manually, as `python manage.py makemigrations` handles this for + you in case you change our models. + +- `models` contains our Django model definitions. We put models into subfolders + relevant as to where they are used - in our case, the `bot` folder contains + models used by our bot when working with the API. Each model is contained + within its own module, such as `api/models/bot/message_deletion_context.py`, + which contains the `MessageDeletionContext` model. + +- `tests` contains tests for our API. If you're unfamilar with Django testing, + the [Django tutorial introducing automated + testing](https://docs.djangoproject.com/en/dev/intro/tutorial05/) is a great + resource, and you can also check out code in there to see how we test it. + +- `viewsets` contains our [DRF + viewsets](https://www.django-rest-framework.org/api-guide/viewsets/), and is + structured similarly to the `models` folder: The `bot` subfolder contains + viewsets relevant to the Python Bot, and each viewset is contained within its + own module. + +The remaining modules mostly do what their name suggests: + +- `admin.py`, which hooks up our models to the [Django admin + site](https://docs.djangoproject.com/en/dev/ref/contrib/admin/). + +- `apps.py` contains the Django [application + config](https://docs.djangoproject.com/en/dev/ref/applications/) for the `api` + app, and is used to run any code that should run when the app is loaded. + +- `pagination.py` contains custom + [paginators](https://www.django-rest-framework.org/api-guide/pagination/) used + within our DRF viewsets. + +- `serializers.py` contains [DRF + serializers](https://www.django-rest-framework.org/api-guide/serializers/) for + our models, and also includes validation logic for the models. + +- `signals.py` contains [Django + Signals](https://docs.djangoproject.com/en/dev/topics/signals/) for running + custom functionality in response to events such as deletion of a model + instance. + +- `urls.py` configures Django's [URL + dispatcher](https://docs.djangoproject.com/en/dev/topics/http/urls/) for our + API endpoints. + +- `views.py` is for any standard Django views that don't make sense to be put + into DRF viewsets as they provide static data or other functionality that + doesn't interact with our models. -- cgit v1.2.3 From 073e09a07723db01691b9fdfc22cfbab037dad96 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 6 Mar 2022 11:19:38 +0100 Subject: Mention human-readable migration names --- pydis_site/apps/api/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/README.md b/pydis_site/apps/api/README.md index 837a89fc..1c6358b3 100644 --- a/pydis_site/apps/api/README.md +++ b/pydis_site/apps/api/README.md @@ -19,7 +19,9 @@ Let's look over each of the subdirectories here: - `migrations` is the standard Django migrations folder. You usually won't need to edit this manually, as `python manage.py makemigrations` handles this for - you in case you change our models. + you in case you change our models. (Note that when generating migrations and + Django doesn't generate a human-readable name for you, please supply one + manually using `-n add_this_field`.) - `models` contains our Django model definitions. We put models into subfolders relevant as to where they are used - in our case, the `bot` folder contains -- cgit v1.2.3 From a6b8c27e68b529b1060b1213b465457c5c0d685a Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 7 Mar 2022 20:18:18 +0100 Subject: Add support for storing AoC related data in site --- .../apps/api/migrations/0080_add_aoc_tables.py | 33 +++++++++++ pydis_site/apps/api/models/__init__.py | 2 + pydis_site/apps/api/models/bot/__init__.py | 2 + .../apps/api/models/bot/aoc_completionist_block.py | 21 +++++++ pydis_site/apps/api/models/bot/aoc_link.py | 20 +++++++ pydis_site/apps/api/serializers.py | 22 +++++++ pydis_site/apps/api/urls.py | 10 ++++ pydis_site/apps/api/viewsets/__init__.py | 2 + pydis_site/apps/api/viewsets/bot/__init__.py | 2 + .../api/viewsets/bot/aoc_completionist_block.py | 69 ++++++++++++++++++++++ pydis_site/apps/api/viewsets/bot/aoc_link.py | 69 ++++++++++++++++++++++ 11 files changed, 252 insertions(+) create mode 100644 pydis_site/apps/api/migrations/0080_add_aoc_tables.py create mode 100644 pydis_site/apps/api/models/bot/aoc_completionist_block.py create mode 100644 pydis_site/apps/api/models/bot/aoc_link.py create mode 100644 pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py create mode 100644 pydis_site/apps/api/viewsets/bot/aoc_link.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py new file mode 100644 index 00000000..f129d86f --- /dev/null +++ b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1.14 on 2022-03-06 16:07 + +from django.db import migrations, models +import django.db.models.deletion +import pydis_site.apps.api.models.mixins + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0079_merge_20220125_2022'), + ] + + operations = [ + migrations.CreateModel( + name='AocCompletionistBlock', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')), + ('user', models.ForeignKey(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')), + ], + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), + ), + migrations.CreateModel( + name='AocAccountLink', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('aoc_username', models.CharField(help_text='The AoC username associated with the Discord User.', max_length=120)), + ('user', models.ForeignKey(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')), + ], + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index fd5bf220..4f616986 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -10,6 +10,8 @@ from .bot import ( Nomination, NominationEntry, OffensiveMessage, + AocAccountLink, + AocCompletionistBlock, OffTopicChannelName, Reminder, Role, diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index ac864de3..ec0e701c 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -5,6 +5,8 @@ from .deleted_message import DeletedMessage from .documentation_link import DocumentationLink from .infraction import Infraction from .message import Message +from .aoc_completionist_block import AocCompletionistBlock +from .aoc_link import AocAccountLink from .message_deletion_context import MessageDeletionContext from .nomination import Nomination, NominationEntry from .off_topic_channel_name import OffTopicChannelName diff --git a/pydis_site/apps/api/models/bot/aoc_completionist_block.py b/pydis_site/apps/api/models/bot/aoc_completionist_block.py new file mode 100644 index 00000000..cac41ff1 --- /dev/null +++ b/pydis_site/apps/api/models/bot/aoc_completionist_block.py @@ -0,0 +1,21 @@ +from django.db import models + +from pydis_site.apps.api.models.bot.user import User +from pydis_site.apps.api.models.mixins import ModelReprMixin + + +class AocCompletionistBlock(ModelReprMixin, models.Model): + """A Discord user blocked from getting the AoC completionist Role.""" + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + help_text="The user that is blocked from getting the AoC Completionist Role" + ) + + is_blocked = models.BooleanField( + default=True, + help_text="Whether this user is actively being blocked " + "from getting the AoC Completionist Role", + verbose_name="Blocked" + ) diff --git a/pydis_site/apps/api/models/bot/aoc_link.py b/pydis_site/apps/api/models/bot/aoc_link.py new file mode 100644 index 00000000..6c7cc591 --- /dev/null +++ b/pydis_site/apps/api/models/bot/aoc_link.py @@ -0,0 +1,20 @@ +from django.db import models + +from pydis_site.apps.api.models.bot.user import User +from pydis_site.apps.api.models.mixins import ModelReprMixin + + +class AocAccountLink(ModelReprMixin, models.Model): + """An AoC account link for a Discord User.""" + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + help_text="The user that is blocked from getting the AoC Completionist Role" + ) + + aoc_username = models.CharField( + max_length=120, + help_text="The AoC username associated with the Discord User.", + blank=False + ) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 745aff42..0b0e4237 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -13,6 +13,8 @@ from rest_framework.settings import api_settings from rest_framework.validators import UniqueTogetherValidator from .models import ( + AocAccountLink, + AocCompletionistBlock, BotSetting, DeletedMessage, DocumentationLink, @@ -250,6 +252,26 @@ class ReminderSerializer(ModelSerializer): ) +class AocCompletionistBlockSerializer(ModelSerializer): + """A class providing (de-)serialization of `AocCompletionistBlock` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = AocCompletionistBlock + fields = ("user", "is_blocked") + + +class AocAccountLinkSerializer(ModelSerializer): + """A class providing (de-)serialization of `AocAccountLink` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = AocAccountLink + fields = ("user", "aoc_username") + + class RoleSerializer(ModelSerializer): """A class providing (de-)serialization of `Role` instances.""" diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index b0ab545b..7c55fc92 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -3,6 +3,8 @@ from rest_framework.routers import DefaultRouter from .views import HealthcheckView, RulesView from .viewsets import ( + AocAccountLinkViewSet, + AocCompletionistBlockViewSet, BotSettingViewSet, DeletedMessageViewSet, DocumentationLinkViewSet, @@ -34,6 +36,14 @@ bot_router.register( 'documentation-links', DocumentationLinkViewSet ) +bot_router.register( + "aoc-account-links", + AocAccountLinkViewSet +) +bot_router.register( + "aoc-completionist-blocks", + AocCompletionistBlockViewSet +) bot_router.register( 'infractions', InfractionViewSet diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index f133e77f..5fc1d64f 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -7,6 +7,8 @@ from .bot import ( InfractionViewSet, NominationViewSet, OffensiveMessageViewSet, + AocAccountLinkViewSet, + AocCompletionistBlockViewSet, OffTopicChannelNameViewSet, ReminderViewSet, RoleViewSet, diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py index 84b87eab..f1d84729 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -7,6 +7,8 @@ from .infraction import InfractionViewSet from .nomination import NominationViewSet from .off_topic_channel_name import OffTopicChannelNameViewSet from .offensive_message import OffensiveMessageViewSet +from .aoc_link import AocAccountLinkViewSet +from .aoc_completionist_block import AocCompletionistBlockViewSet from .reminder import ReminderViewSet from .role import RoleViewSet from .user import UserViewSet diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py new file mode 100644 index 00000000..53bcb546 --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py @@ -0,0 +1,69 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.mixins import ( + CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin +) +from rest_framework.viewsets import GenericViewSet + +from pydis_site.apps.api.models.bot import AocCompletionistBlock +from pydis_site.apps.api.serializers import AocCompletionistBlockSerializer + + +class AocCompletionistBlockViewSet( + GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin +): + """ + View providing management for Users blocked from gettign the AoC completionist Role. + + ## Routes + + ### GET /bot/aoc-completionist-blocks/ + Returns all the AoC completionist blocks + + #### Response format + >>> [ + ... { + ... "user": 2, + ... "is_blocked": False + ... } + ... ] + + + ### GET /bot/aoc-completionist-blocks/ + Retrieve a single Block by User ID + + #### Response format + >>> + ... { + ... "user": 2, + ... "is_blocked": False + ... } + + #### Status codes + - 200: returned on success + - 404: returned if an AoC completionist block with the given user__id was not found. + + ### POST /bot/aoc-completionist-blocks + Adds a single AoC completionist block + + #### Request body + >>> { + ... 'user': int, + ... 'is_blocked': bool + ... } + + #### Status codes + - 204: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/aoc-completionist-blocks/ + Deletes the AoC Completionist block item with the given `user__id`. + #### Status codes + - 204: returned on success + - 404: if the AoC Completionist block with the given user__id does not exist + + """ + + serializer_class = AocCompletionistBlockSerializer + queryset = AocCompletionistBlock.objects.all() + filter_backends = (DjangoFilterBackend,) + filter_fields = ("user__id",) diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py new file mode 100644 index 00000000..b5b5420e --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py @@ -0,0 +1,69 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.mixins import ( + CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin +) +from rest_framework.viewsets import GenericViewSet + +from pydis_site.apps.api.models.bot import AocAccountLink +from pydis_site.apps.api.serializers import AocAccountLinkSerializer + + +class AocAccountLinkViewSet( + GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin +): + """ + View providing management for Users who linked their AoC accounts to their Discord Account. + + ## Routes + + ### GET /bot/aoc-account-links + Returns all the AoC account links + + #### Response format + >>> [ + ... { + ... "user": 2, + ... "aoc_username": "AoCUser1" + ... } + ... ] + + + ### GET /bot/aoc-account-links + Retrieve a AoC account link by User ID + + #### Response format + >>> + ... { + ... "user": 2, + ... "aoc_username": "AoCUser1" + ... } + + #### Status codes + - 200: returned on success + - 404: returned if an AoC account link with the given user__id was not found. + + ### POST /bot/aoc-account-links + Adds a single AoC account link block + + #### Request body + >>> { + ... 'user': int, + ... 'aoc_username': str + ... } + + #### Status codes + - 204: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/aoc-account-links/ + Deletes the AoC account link item with the given `user__id`. + #### Status codes + - 204: returned on success + - 404: if the AoC account link with the given user__id does not exist + + """ + + serializer_class = AocAccountLinkSerializer + queryset = AocAccountLink.objects.all() + filter_backends = (DjangoFilterBackend,) + filter_fields = ("user__id",) -- cgit v1.2.3 From 19c6e7b66cf5078a198f4a70fec38d66dd029564 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Tue, 8 Mar 2022 15:54:34 +0100 Subject: Enhance comments and table structure in AoC related modules - Set the user reference to be a OneToOne relation, on tables: AocCompletionistBlock and AocAccountLink. --- pydis_site/apps/api/migrations/0080_add_aoc_tables.py | 4 ++-- pydis_site/apps/api/models/bot/aoc_completionist_block.py | 2 +- pydis_site/apps/api/models/bot/aoc_link.py | 2 +- pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py | 4 ++-- pydis_site/apps/api/viewsets/bot/aoc_link.py | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py index f129d86f..c58a5d29 100644 --- a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py +++ b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py @@ -17,7 +17,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')), - ('user', models.ForeignKey(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')), + ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')), ], bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), @@ -26,7 +26,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('aoc_username', models.CharField(help_text='The AoC username associated with the Discord User.', max_length=120)), - ('user', models.ForeignKey(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')), + ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')), ], bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), diff --git a/pydis_site/apps/api/models/bot/aoc_completionist_block.py b/pydis_site/apps/api/models/bot/aoc_completionist_block.py index cac41ff1..a89f9760 100644 --- a/pydis_site/apps/api/models/bot/aoc_completionist_block.py +++ b/pydis_site/apps/api/models/bot/aoc_completionist_block.py @@ -7,7 +7,7 @@ from pydis_site.apps.api.models.mixins import ModelReprMixin class AocCompletionistBlock(ModelReprMixin, models.Model): """A Discord user blocked from getting the AoC completionist Role.""" - user = models.ForeignKey( + user = models.OneToOneField( User, on_delete=models.CASCADE, help_text="The user that is blocked from getting the AoC Completionist Role" diff --git a/pydis_site/apps/api/models/bot/aoc_link.py b/pydis_site/apps/api/models/bot/aoc_link.py index 6c7cc591..9b47456d 100644 --- a/pydis_site/apps/api/models/bot/aoc_link.py +++ b/pydis_site/apps/api/models/bot/aoc_link.py @@ -7,7 +7,7 @@ from pydis_site.apps.api.models.mixins import ModelReprMixin class AocAccountLink(ModelReprMixin, models.Model): """An AoC account link for a Discord User.""" - user = models.ForeignKey( + user = models.OneToOneField( User, on_delete=models.CASCADE, help_text="The user that is blocked from getting the AoC Completionist Role" diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py index 53bcb546..c5568129 100644 --- a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py +++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py @@ -47,8 +47,8 @@ class AocCompletionistBlockViewSet( #### Request body >>> { - ... 'user': int, - ... 'is_blocked': bool + ... 'user': int, + ... 'is_blocked': bool ... } #### Status codes diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py index b5b5420e..263b548d 100644 --- a/pydis_site/apps/api/viewsets/bot/aoc_link.py +++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py @@ -47,8 +47,8 @@ class AocAccountLinkViewSet( #### Request body >>> { - ... 'user': int, - ... 'aoc_username': str + ... 'user': int, + ... 'aoc_username': str ... } #### Status codes -- cgit v1.2.3 From 955122d028b81529fffbf73f9298d0f06cb2e412 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 9 Mar 2022 04:02:29 +0200 Subject: Change ping fields names --- pydis_site/apps/api/migrations/0079_new_filter_schema.py | 16 ++++++++-------- pydis_site/apps/api/models/bot/filters.py | 8 ++++---- pydis_site/apps/api/serializers.py | 6 +++--- 3 files changed, 15 insertions(+), 15 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py index 89f70799..b67740d2 100644 --- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py @@ -37,9 +37,9 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: list_ = filter_list.objects.create( name=change_map[name], list_type=int(type_), - ping_type=(["Moderators"] if name != "FILE_FORMAT" else []), + guild_pings=(["Moderators"] if name != "FILE_FORMAT" else []), filter_dm=True, - dm_ping_type=[], + dm_pings=[], delete_messages=(True if name != "FILTER_TOKEN" else False), bypass_roles=["Helpers"], enabled=True, @@ -60,9 +60,9 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: filter_list=list_, description=object_.comment, additional_field=None, - ping_type=None, + guild_pings=None, filter_dm=None, - dm_ping_type=None, + dm_pings=None, delete_messages=None, bypass_roles=None, enabled=None, @@ -97,9 +97,9 @@ class Migration(migrations.Migration): ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)), ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)), - ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), + ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), - ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), + ('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, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)), ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field], null=True)), ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)), @@ -120,9 +120,9 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')), - ('ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), + ('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, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')), - ('dm_ping_type', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), + ('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, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')), ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field])), ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')), diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 97af21f8..4dbf1875 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -84,14 +84,14 @@ class FilterList(FilterSettingsMixin): choices=FilterListType.choices, help_text="Whether this list is an allowlist or denylist" ) - ping_type = ArrayField( + guild_pings = ArrayField( models.CharField(max_length=20), validators=(validate_ping_field,), help_text="Who to ping when this filter triggers.", null=False ) filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False) - dm_ping_type = ArrayField( + dm_pings = ArrayField( models.CharField(max_length=20), validators=(validate_ping_field,), help_text="Who to ping when this filter triggers on a DM.", @@ -147,14 +147,14 @@ class Filter(FilterSettingsMixin): FilterList, models.CASCADE, related_name="filters", help_text="The filter list containing this filter." ) - ping_type = ArrayField( + guild_pings = ArrayField( models.CharField(max_length=20), validators=(validate_ping_field,), help_text="Who to ping when this filter triggers.", null=True ) filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True) - dm_ping_type = ArrayField( + dm_pings = ArrayField( models.CharField(max_length=20), validators=(validate_ping_field,), help_text="Who to ping when this filter triggers on a DM.", diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 99f2b630..5a637976 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -121,9 +121,9 @@ ALWAYS_OPTIONAL_SETTINGS = ( ) REQUIRED_FOR_FILTER_LIST_SETTINGS = ( - 'ping_type', + 'guild_pings', 'filter_dm', - 'dm_ping_type', + 'dm_pings', 'delete_messages', 'bypass_roles', 'enabled', @@ -154,7 +154,7 @@ CHANNEL_SCOPE_FIELDS = ( "disabled_categories", "enabled_channels", ) -MENTIONS_FIELDS = ("ping_type", "dm_ping_type") +MENTIONS_FIELDS = ("guild_pings", "dm_pings") SETTINGS_FIELDS = ALWAYS_OPTIONAL_SETTINGS + REQUIRED_FOR_FILTER_LIST_SETTINGS -- cgit v1.2.3 From 63fa1c03977435a2aa92325f8872e2d222d9e28a Mon Sep 17 00:00:00 2001 From: Krypton Date: Wed, 9 Mar 2022 15:42:05 +0100 Subject: Create discord-messages-with-colors.md --- .../python-guides/discord-messages-with-colors.md | 64 ++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md (limited to 'pydis_site/apps') 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 new file mode 100644 index 00000000..60ea8656 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md @@ -0,0 +1,64 @@ +--- +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 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 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: +``` +\u001b[{format};{color}m +``` +*The `\u001b` is the unicode for ESCAPE/ESC, see .* ***If you want to use it yourself without bots, then you need to copy paste the character from the website.*** + +After you've written this, you can type and text you wish, and if you want to reset the color back to normal, then you need to use `\u001b[0m` as prefix. + +Here is the list of values you can use to replace `{format}`: + +* 0: Normal +* 1: **Bold** +* 4: Underline + +Here is the list of values you can use to replace `{color}`: + +*The following values will change the **text** color.* + +* 30: Gray +* 31: Red +* 32: Green +* 33: Yellow +* 34: Blue +* 35: Pink +* 36: Cyan +* 37: White + +*The following values will change the **text background** color.* + +* 40: Some very dark blue +* 41: Orange +* 42: Gray +* 43: Light gray +* 44: Even lighter gray +* 45: Indigo +* 46: Again some gray +* 47: White + +Let's take an example, I want a bold green colored text with the very 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. + +Raw message:
      +\`\`\`ansi
      +\u001b[0;40m\u001b[1;32mThat's some cool formatted text right?
      +or
      +\u001b[1;40;32mThat's some cool formatted text right?
      +\`\`\` + +Result:
      +![Background and text color](https://media.discordapp.net/attachments/739937507768270939/930460020603224084/Background-Text-Color.png) + +The way the colors look like on Discord is shown in the image below ^^ + +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: + +![ANSI Colors](https://media.discordapp.net/attachments/739937507768270939/930825555803263016/ANSI-Colors.png) -- cgit v1.2.3 From b93dce5abcf225579b9407358f938ca3932e67a2 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Wed, 9 Mar 2022 19:37:30 +0100 Subject: Add reason field to AoC completionist block table --- pydis_site/apps/api/migrations/0080_add_aoc_tables.py | 1 + pydis_site/apps/api/models/bot/aoc_completionist_block.py | 4 ++++ pydis_site/apps/api/serializers.py | 2 +- pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py | 7 +++++-- 4 files changed, 11 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py index c58a5d29..917c5b7f 100644 --- a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py +++ b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py @@ -17,6 +17,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')), + ('reason', models.TextField(help_text='The reason for the AoC Completionist Role Block.', null=True)), ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')), ], bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), diff --git a/pydis_site/apps/api/models/bot/aoc_completionist_block.py b/pydis_site/apps/api/models/bot/aoc_completionist_block.py index a89f9760..6605cbc4 100644 --- a/pydis_site/apps/api/models/bot/aoc_completionist_block.py +++ b/pydis_site/apps/api/models/bot/aoc_completionist_block.py @@ -19,3 +19,7 @@ class AocCompletionistBlock(ModelReprMixin, models.Model): "from getting the AoC Completionist Role", verbose_name="Blocked" ) + reason = models.TextField( + null=True, + help_text="The reason for the AoC Completionist Role Block." + ) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 0b0e4237..c97f7dba 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -259,7 +259,7 @@ class AocCompletionistBlockSerializer(ModelSerializer): """Metadata defined for the Django REST Framework.""" model = AocCompletionistBlock - fields = ("user", "is_blocked") + fields = ("user", "is_blocked", "reason") class AocAccountLinkSerializer(ModelSerializer): diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py index c5568129..8e7d821c 100644 --- a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py +++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py @@ -24,6 +24,7 @@ class AocCompletionistBlockViewSet( ... { ... "user": 2, ... "is_blocked": False + ... "reason": "Too good to be true" ... } ... ] @@ -36,6 +37,7 @@ class AocCompletionistBlockViewSet( ... { ... "user": 2, ... "is_blocked": False + ... "reason": "Too good to be true" ... } #### Status codes @@ -47,8 +49,9 @@ class AocCompletionistBlockViewSet( #### Request body >>> { - ... 'user': int, - ... 'is_blocked': bool + ... "user": int, + ... "is_blocked": bool + ... "reason": string ... } #### Status codes -- cgit v1.2.3 From d18d0198f2a43066b7f6cb9542a25adea6e6b3f4 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Wed, 9 Mar 2022 23:07:38 +0100 Subject: Patch AoC tables to use the Discord user as PK. --- pydis_site/apps/api/migrations/0080_add_aoc_tables.py | 16 +++++++--------- .../apps/api/models/bot/aoc_completionist_block.py | 3 ++- pydis_site/apps/api/models/bot/aoc_link.py | 3 ++- pydis_site/apps/api/viewsets/bot/aoc_link.py | 8 +++++--- 4 files changed, 16 insertions(+), 14 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py index 917c5b7f..2c0c689a 100644 --- a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py +++ b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py @@ -13,21 +13,19 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='AocCompletionistBlock', + name='AocAccountLink', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')), - ('reason', models.TextField(help_text='The reason for the AoC Completionist Role Block.', null=True)), - ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')), + ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='api.user')), + ('aoc_username', models.CharField(help_text='The AoC username associated with the Discord User.', max_length=120)), ], bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), migrations.CreateModel( - name='AocAccountLink', + name='AocCompletionistBlock', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('aoc_username', models.CharField(help_text='The AoC username associated with the Discord User.', max_length=120)), - ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, to='api.user')), + ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='api.user')), + ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')), + ('reason', models.TextField(help_text='The reason for the AoC Completionist Role Block.', null=True)), ], bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), diff --git a/pydis_site/apps/api/models/bot/aoc_completionist_block.py b/pydis_site/apps/api/models/bot/aoc_completionist_block.py index 6605cbc4..acbc0eba 100644 --- a/pydis_site/apps/api/models/bot/aoc_completionist_block.py +++ b/pydis_site/apps/api/models/bot/aoc_completionist_block.py @@ -10,7 +10,8 @@ class AocCompletionistBlock(ModelReprMixin, models.Model): user = models.OneToOneField( User, on_delete=models.CASCADE, - help_text="The user that is blocked from getting the AoC Completionist Role" + help_text="The user that is blocked from getting the AoC Completionist Role", + primary_key=True ) is_blocked = models.BooleanField( diff --git a/pydis_site/apps/api/models/bot/aoc_link.py b/pydis_site/apps/api/models/bot/aoc_link.py index 9b47456d..4e9d4882 100644 --- a/pydis_site/apps/api/models/bot/aoc_link.py +++ b/pydis_site/apps/api/models/bot/aoc_link.py @@ -10,7 +10,8 @@ class AocAccountLink(ModelReprMixin, models.Model): user = models.OneToOneField( User, on_delete=models.CASCADE, - help_text="The user that is blocked from getting the AoC Completionist Role" + help_text="The user that is blocked from getting the AoC Completionist Role", + primary_key=True ) aoc_username = models.CharField( diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py index 263b548d..c3fa6854 100644 --- a/pydis_site/apps/api/viewsets/bot/aoc_link.py +++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py @@ -24,7 +24,8 @@ class AocAccountLinkViewSet( ... { ... "user": 2, ... "aoc_username": "AoCUser1" - ... } + ... }, + ... ... ... ] @@ -32,11 +33,12 @@ class AocAccountLinkViewSet( Retrieve a AoC account link by User ID #### Response format - >>> + >>> [ ... { ... "user": 2, ... "aoc_username": "AoCUser1" - ... } + ... }, + ... ] #### Status codes - 200: returned on success -- cgit v1.2.3 From 934218e86b8e72ee999e7c086aa0ec7cabf08408 Mon Sep 17 00:00:00 2001 From: Krypton Date: Thu, 10 Mar 2022 10:26:23 +0100 Subject: Update discord-messages-with-colors.md --- .../resources/guides/python-guides/discord-messages-with-colors.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') 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 60ea8656..30f40948 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 @@ -1,5 +1,5 @@ --- -title: Discord messages with colors +title: Discord Messages with Colors description: A guide on how to add colors to your codeblocks on Discord --- @@ -57,8 +57,7 @@ or
      Result:
      ![Background and text color](https://media.discordapp.net/attachments/739937507768270939/930460020603224084/Background-Text-Color.png) -The way the colors look like on Discord is shown in the image below ^^ +The way the colors look like on Discord is shown in the image below: +![ANSI Colors](https://media.discordapp.net/attachments/739937507768270939/930825555803263016/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: - -![ANSI Colors](https://media.discordapp.net/attachments/739937507768270939/930825555803263016/ANSI-Colors.png) -- cgit v1.2.3 From 8c95f13c96d16d6f4d0736ee136563d603926c63 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Thu, 10 Mar 2022 17:28:39 +0100 Subject: Enhance code, documentation consistency in AoC related code Co-authored-by: Mark <1515135+MarkKoz@users.noreply.github.com> --- pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py | 7 ++++--- pydis_site/apps/api/viewsets/bot/aoc_link.py | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py index 8e7d821c..d3167d7b 100644 --- a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py +++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py @@ -23,7 +23,7 @@ class AocCompletionistBlockViewSet( >>> [ ... { ... "user": 2, - ... "is_blocked": False + ... "is_blocked": False, ... "reason": "Too good to be true" ... } ... ] @@ -36,7 +36,7 @@ class AocCompletionistBlockViewSet( >>> ... { ... "user": 2, - ... "is_blocked": False + ... "is_blocked": False, ... "reason": "Too good to be true" ... } @@ -50,7 +50,7 @@ class AocCompletionistBlockViewSet( #### Request body >>> { ... "user": int, - ... "is_blocked": bool + ... "is_blocked": bool, ... "reason": string ... } @@ -60,6 +60,7 @@ class AocCompletionistBlockViewSet( ### DELETE /bot/aoc-completionist-blocks/ Deletes the AoC Completionist block item with the given `user__id`. + #### Status codes - 204: returned on success - 404: if the AoC Completionist block with the given user__id does not exist diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py index c3fa6854..5f6f3a84 100644 --- a/pydis_site/apps/api/viewsets/bot/aoc_link.py +++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py @@ -59,6 +59,7 @@ class AocAccountLinkViewSet( ### DELETE /bot/aoc-account-links/ Deletes the AoC account link item with the given `user__id`. + #### Status codes - 204: returned on success - 404: if the AoC account link with the given user__id does not exist -- cgit v1.2.3 From 05e2bce1e82e422755396d1e6e489d6792ec0115 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 10 Mar 2022 20:56:47 +0200 Subject: Remove role validation Roles can be either IDs or names, so the current validation is not relevant anymore. Furthermore the ping fields can accept user IDs or names. --- .../apps/api/migrations/0079_new_filter_schema.py | 12 ++++---- pydis_site/apps/api/models/bot/filters.py | 32 ---------------------- pydis_site/apps/api/tests/test_filters.py | 11 -------- 3 files changed, 6 insertions(+), 49 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py index b67740d2..053f9782 100644 --- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py @@ -97,11 +97,11 @@ class Migration(migrations.Migration): ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)), ('additional_field', django.contrib.postgres.fields.jsonb.JSONField(help_text='Implementation specific field.', null=True)), - ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), + ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, null=True)), ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), - ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field], null=True)), + ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, null=True)), ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.', null=True)), - ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field], null=True)), + ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, null=True)), ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)), ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)), @@ -120,11 +120,11 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')), - ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), + ('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, validators=[pydis_site.apps.api.models.bot.filters.validate_ping_field])), + ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None)), ('delete_messages', models.BooleanField(help_text='Whether this filter should delete messages triggering it.')), - ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, validators=[pydis_site.apps.api.models.bot.filters.validate_bypass_roles_field])), + ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None)), ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')), ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)), diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 4dbf1875..13b332d2 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -16,32 +16,6 @@ class FilterListType(models.IntegerChoices): DENY = 0 -# Valid special values in ping related fields -VALID_PINGS = ("everyone", "here", "moderators", "onduty", "admins") -VALID_BYPASS_ROLES = ("staff",) - - -def validate_ping_field(value_list: List[str]) -> None: - """Validate that the values are either a special value or a UID.""" - for value in value_list: - # Check if it is a special value - if value in VALID_PINGS: - continue - # Check if it is a UID - if value.isnumeric(): - continue - - raise ValidationError(f"{value!r} isn't a valid ping type.") - - -def validate_bypass_roles_field(value_list: List[str]) -> None: - """Validate that the vclues are either a special value or a Role ID.""" - for value in value_list: - if value.isnumeric() or value in VALID_BYPASS_ROLES: - continue - raise ValidationError(f"{value!r} isn't a valid (bypass) role.") - - class FilterSettingsMixin(models.Model): """Mixin for common settings of a filters and filter lists.""" @@ -86,14 +60,12 @@ class FilterList(FilterSettingsMixin): ) guild_pings = ArrayField( models.CharField(max_length=20), - validators=(validate_ping_field,), help_text="Who to ping when this filter triggers.", null=False ) filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False) dm_pings = ArrayField( models.CharField(max_length=20), - validators=(validate_ping_field,), help_text="Who to ping when this filter triggers on a DM.", null=False ) @@ -104,7 +76,6 @@ class FilterList(FilterSettingsMixin): bypass_roles = ArrayField( models.CharField(max_length=100), help_text="Roles and users who can bypass this filter.", - validators=(validate_bypass_roles_field,), null=False ) enabled = models.BooleanField( @@ -149,14 +120,12 @@ class Filter(FilterSettingsMixin): ) guild_pings = ArrayField( models.CharField(max_length=20), - validators=(validate_ping_field,), help_text="Who to ping when this filter triggers.", null=True ) filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True) dm_pings = ArrayField( models.CharField(max_length=20), - validators=(validate_ping_field,), help_text="Who to ping when this filter triggers on a DM.", null=True ) @@ -167,7 +136,6 @@ class Filter(FilterSettingsMixin): bypass_roles = ArrayField( models.CharField(max_length=100), help_text="Roles and users who can bypass this filter.", - validators=(validate_bypass_roles_field,), null=True ) enabled = models.BooleanField( diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py index f694053d..5f40c6f9 100644 --- a/pydis_site/apps/api/tests/test_filters.py +++ b/pydis_site/apps/api/tests/test_filters.py @@ -296,14 +296,3 @@ class GenericFilterTest(APISubdomainTestCase): response = self.client.delete(f"{sequence.url()}/42") self.assertEqual(response.status_code, 404) - - def test_reject_invalid_ping(self) -> None: - url = reverse('bot:filteroverride-list', host='api') - data = { - "ping_type": ["invalid"] - } - - response = self.client.post(url, data=data) - - self.assertEqual(response.status_code, 400) - self.assertDictEqual(response.json(), {'ping_type': ["'invalid' isn't a valid ping type."]}) -- cgit v1.2.3 From 083cdf3f49805fd3c6d01fe538b7e01e12ca9b79 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Thu, 10 Mar 2022 19:18:05 +0100 Subject: Add new filter field, and patch the docs in AoC viewsets - Add the possibility to filter by `is_blocked` in the AoC completionist block viewset. - Patch various tense, and formatting inconsistencies in AoC viewsets --- .../apps/api/viewsets/bot/aoc_completionist_block.py | 6 +++--- pydis_site/apps/api/viewsets/bot/aoc_link.py | 19 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py index d3167d7b..3a4cec60 100644 --- a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py +++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py @@ -42,7 +42,7 @@ class AocCompletionistBlockViewSet( #### Status codes - 200: returned on success - - 404: returned if an AoC completionist block with the given user__id was not found. + - 404: returned if an AoC completionist block with the given `user__id` was not found. ### POST /bot/aoc-completionist-blocks Adds a single AoC completionist block @@ -63,11 +63,11 @@ class AocCompletionistBlockViewSet( #### Status codes - 204: returned on success - - 404: if the AoC Completionist block with the given user__id does not exist + - 404: returned if the AoC Completionist block with the given `user__id` was not found """ serializer_class = AocCompletionistBlockSerializer queryset = AocCompletionistBlock.objects.all() filter_backends = (DjangoFilterBackend,) - filter_fields = ("user__id",) + filter_fields = ("user__id", "is_blocked") diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py index 5f6f3a84..9f22c1a1 100644 --- a/pydis_site/apps/api/viewsets/bot/aoc_link.py +++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py @@ -29,20 +29,19 @@ class AocAccountLinkViewSet( ... ] - ### GET /bot/aoc-account-links + ### GET /bot/aoc-account-links/ Retrieve a AoC account link by User ID #### Response format - >>> [ - ... { - ... "user": 2, - ... "aoc_username": "AoCUser1" - ... }, - ... ] + >>> + ... { + ... "user": 2, + ... "aoc_username": "AoCUser1" + ... } #### Status codes - 200: returned on success - - 404: returned if an AoC account link with the given user__id was not found. + - 404: returned if an AoC account link with the given `user__id` was not found. ### POST /bot/aoc-account-links Adds a single AoC account link block @@ -55,14 +54,14 @@ class AocAccountLinkViewSet( #### Status codes - 204: returned on success - - 400: if one of the given fields is invalid + - 400: if one of the given fields was invalid ### DELETE /bot/aoc-account-links/ Deletes the AoC account link item with the given `user__id`. #### Status codes - 204: returned on success - - 404: if the AoC account link with the given user__id does not exist + - 404: returned if the AoC account link with the given `user__id` was not found """ -- cgit v1.2.3 From c0666e5095c6e427b786fb934520d63fabaa8840 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Mar 2022 16:41:11 +0100 Subject: Add a README for the content directory --- pydis_site/apps/content/README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 pydis_site/apps/content/README.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/README.md b/pydis_site/apps/content/README.md new file mode 100644 index 00000000..b2719a9f --- /dev/null +++ b/pydis_site/apps/content/README.md @@ -0,0 +1,37 @@ +# The "content" app + +This application serves static, markdown-based content. Django-wise there is +relatively little code in there, most of it is concerned with serving our +content. + +The markdown files hosting our content can be found in the +[`resources/`](./resources) directory, and contain + + +## Contributing pages + +Contributing pages is covered extensively in our online guide, which you can +find +[here](https://www.pythondiscord.com/pages/guides/pydis-guides/how-to-contribute-a-page/) +or alternatively read it directly at +[`resources/guides/pydis-guides/how-to-contribute-a-page.md`](./resources/guides/pydis-guides/how-to-contribute-a-page.md). + + +## Directory structure + +Let's look at the structure in here: + +- `resources` contains the static Markdown files that make up our site's + [pages](https://www.pythondiscord.com/pages/) + +- `migrations` contains standard Django migrations. As the `content` app + contains purely static markdown files, no migrations are present here. + +- `tests` contains unit tests for verifying that the app works properly. + +- `views` contains our Django views generating and serving the pages from the + input markdown. + +As for the modules, apart from the standard Django modules in here, the +`utils.py` module contains utility functions for discovering markdown files to +serve on the filesystem. -- cgit v1.2.3 From c7300a92c885b01a5663913fa73679fc680bfb74 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Sat, 12 Mar 2022 16:48:54 +0100 Subject: Sync Filter models with relating migrations, adjust code consistency --- .../apps/api/migrations/0079_new_filter_schema.py | 2 +- pydis_site/apps/api/models/bot/filters.py | 57 ++++++++++++++-------- 2 files changed, 39 insertions(+), 20 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0079_new_filter_schema.py index 053f9782..bd807f02 100644 --- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0079_new_filter_schema.py @@ -129,7 +129,7 @@ class Migration(migrations.Migration): ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True)), ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True)), ('infraction_type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The infraction to apply to this user.', max_length=9, null=True)), - ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000)), + ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True)), ('infraction_duration', models.DurationField(help_text='The duration of the infraction. Null if permanent.', null=True)), ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter.", size=None)), ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", size=None)), diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 13b332d2..f8bbfd14 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -1,7 +1,4 @@ -from typing import List - from django.contrib.postgres.fields import ArrayField, JSONField -from django.core.exceptions import ValidationError from django.db import models from django.db.models import UniqueConstraint @@ -37,7 +34,8 @@ class FilterSettingsMixin(models.Model): ) infraction_reason = models.CharField( max_length=1000, - help_text="The reason to give for the infraction." + help_text="The reason to give for the infraction.", + null=True ) infraction_duration = models.DurationField( null=True, @@ -59,13 +57,13 @@ class FilterList(FilterSettingsMixin): help_text="Whether this list is an allowlist or denylist" ) guild_pings = ArrayField( - models.CharField(max_length=20), + 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=20), + models.CharField(max_length=100), help_text="Who to ping when this filter triggers on a DM.", null=False ) @@ -83,9 +81,7 @@ class FilterList(FilterSettingsMixin): null=False ) send_alert = models.BooleanField( - help_text="Whether alert should be sent.", - null=False, - default=True + help_text="Whether an alert should be sent.", ) # Where a filter should apply. # @@ -93,9 +89,18 @@ class FilterList(FilterSettingsMixin): # - enabled_channels # - disabled_categories # - disabled_channels - enabled_channels = ArrayField(models.IntegerField()) - disabled_channels = ArrayField(models.IntegerField()) - disabled_categories = ArrayField(models.IntegerField()) + 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." + ) + 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.""" @@ -112,20 +117,23 @@ class Filter(FilterSettingsMixin): """One specific trigger of a list.""" content = models.CharField(max_length=100, help_text="The definition of this filter.") - description = models.CharField(max_length=200, help_text="Why this filter has been added.") + description = models.CharField( + max_length=200, + help_text="Why this filter has been added.", null=True + ) additional_field = JSONField(null=True, help_text="Implementation specific field.") filter_list = models.ForeignKey( FilterList, models.CASCADE, related_name="filters", help_text="The filter list containing this filter." ) guild_pings = ArrayField( - models.CharField(max_length=20), + 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=20), + models.CharField(max_length=100), help_text="Who to ping when this filter triggers on a DM.", null=True ) @@ -143,14 +151,25 @@ class Filter(FilterSettingsMixin): null=True ) send_alert = models.BooleanField( - help_text="Whether alert should be sent.", + help_text="Whether an alert should be sent.", null=True ) # Check FilterList model for information about these properties. - enabled_channels = ArrayField(models.IntegerField(), null=True) - disabled_channels = ArrayField(models.IntegerField(), null=True) - disabled_categories = ArrayField(models.IntegerField(), null=True) + enabled_channels = ArrayField( + models.CharField(max_length=100), + help_text="Channels in which to run the filter even if it's disabled in the category.", + null=True + ) + disabled_channels = ArrayField( + models.CharField(max_length=100), + help_text="Channels in which to not run the filter.", null=True + ) + 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}" -- cgit v1.2.3 From ca2c583b15d46d7821a03ec047678429f96c7e03 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Mar 2022 21:51:28 +0100 Subject: Reword documentation per review Co-authored-by: MarkKoz --- pydis_site/apps/content/README.md | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/README.md b/pydis_site/apps/content/README.md index b2719a9f..d36d6bee 100644 --- a/pydis_site/apps/content/README.md +++ b/pydis_site/apps/content/README.md @@ -1,19 +1,17 @@ # The "content" app -This application serves static, markdown-based content. Django-wise there is -relatively little code in there, most of it is concerned with serving our +This application serves static, Markdown-based content. Django-wise there is +relatively little code in there; most of it is concerned with serving our content. -The markdown files hosting our content can be found in the -[`resources/`](./resources) directory, and contain - ## Contributing pages -Contributing pages is covered extensively in our online guide, which you can -find -[here](https://www.pythondiscord.com/pages/guides/pydis-guides/how-to-contribute-a-page/) -or alternatively read it directly at +The Markdown files hosting our content can be found in the +[`resources/`](./resources) directory. The process of contributing to pages is +covered extensively in our online guide which you can find +[here](https://www.pythondiscord.com/pages/guides/pydis-guides/how-to-contribute-a-page/). +Alternatively, read it directly at [`resources/guides/pydis-guides/how-to-contribute-a-page.md`](./resources/guides/pydis-guides/how-to-contribute-a-page.md). @@ -25,13 +23,13 @@ Let's look at the structure in here: [pages](https://www.pythondiscord.com/pages/) - `migrations` contains standard Django migrations. As the `content` app - contains purely static markdown files, no migrations are present here. + contains purely static Markdown files, no migrations are present here. - `tests` contains unit tests for verifying that the app works properly. -- `views` contains our Django views generating and serving the pages from the - input markdown. +- `views` contains Django views which generating and serve the pages from the + input Markdown. As for the modules, apart from the standard Django modules in here, the -`utils.py` module contains utility functions for discovering markdown files to -serve on the filesystem. +`utils.py` module contains utility functions for discovering Markdown files to +serve. -- cgit v1.2.3 From 4ad92b98a0df047b12e302ce8d849c2bd85862f1 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Mar 2022 21:53:53 +0100 Subject: Remove unused migrations package --- pydis_site/apps/content/README.md | 3 --- pydis_site/apps/content/migrations/__init__.py | 0 2 files changed, 3 deletions(-) delete mode 100644 pydis_site/apps/content/migrations/__init__.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/README.md b/pydis_site/apps/content/README.md index d36d6bee..4149a6f0 100644 --- a/pydis_site/apps/content/README.md +++ b/pydis_site/apps/content/README.md @@ -22,9 +22,6 @@ Let's look at the structure in here: - `resources` contains the static Markdown files that make up our site's [pages](https://www.pythondiscord.com/pages/) -- `migrations` contains standard Django migrations. As the `content` app - contains purely static Markdown files, no migrations are present here. - - `tests` contains unit tests for verifying that the app works properly. - `views` contains Django views which generating and serve the pages from the diff --git a/pydis_site/apps/content/migrations/__init__.py b/pydis_site/apps/content/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 -- cgit v1.2.3 From 2bf88327c6298e9004aeda028576946b1704d799 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Mar 2022 22:43:52 +0100 Subject: Correct typo Co-authored-by: Mark <1515135+MarkKoz@users.noreply.github.com> --- pydis_site/apps/content/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/README.md b/pydis_site/apps/content/README.md index 4149a6f0..e7061207 100644 --- a/pydis_site/apps/content/README.md +++ b/pydis_site/apps/content/README.md @@ -24,7 +24,7 @@ Let's look at the structure in here: - `tests` contains unit tests for verifying that the app works properly. -- `views` contains Django views which generating and serve the pages from the +- `views` contains Django views which generate and serve the pages from the input Markdown. As for the modules, apart from the standard Django modules in here, the -- cgit v1.2.3 From a52a2cf631d92173f1a5573a56d3347d6456ac39 Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 13 Mar 2022 14:31:09 -0400 Subject: Update Project Lead color --- pydis_site/apps/content/resources/server-info/roles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/server-info/roles.md b/pydis_site/apps/content/resources/server-info/roles.md index 716f5b1e..2888f837 100644 --- a/pydis_site/apps/content/resources/server-info/roles.md +++ b/pydis_site/apps/content/resources/server-info/roles.md @@ -68,7 +68,7 @@ In addition to the informal descriptions below, we've also written down a more f ### Domain Leads **Description:** Staff in charge of a certain domain such as moderation, events, and outreach. A lead will have a second role specifying their domain. -### Project Leads +### Project Leads **Description:** Staff in charge of a certain project that require special attention, such as a YouTube video series or our new forms page. ### Moderators -- cgit v1.2.3 From 06079418f5610a63c84dcc6c979ade347c6c5451 Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 13 Mar 2022 14:40:14 -0400 Subject: Add Events Team --- pydis_site/apps/content/resources/server-info/roles.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/server-info/roles.md b/pydis_site/apps/content/resources/server-info/roles.md index 2888f837..edc02066 100644 --- a/pydis_site/apps/content/resources/server-info/roles.md +++ b/pydis_site/apps/content/resources/server-info/roles.md @@ -80,8 +80,8 @@ In addition to the informal descriptions below, we've also written down a more f ### DevOps **Description:** A role for staff involved with the DevOps toolchain of our core projects. -### Project Teams -**Description:** Staff can join teams which work on specific projects in the organisation, such as our code jams, media projects, and more. +### Events Team +**Description:** The events team are staff members who help plan and execute Python Discord events. This can range from the Code Jam, to Pixels, to our survey, specific workshops we want to run, and more. ### Helpers **Description:** This is the core staff role in our organization: All staff members have the Helpers role. -- cgit v1.2.3 From 40bb274538b80eacc7e2435391af9a87d90ab23d Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 13 Mar 2022 16:15:49 -0400 Subject: Preliminary structure --- .../resources/guides/pydis-guides/contributing.md | 55 ++++++++++++++++++---- 1 file changed, 46 insertions(+), 9 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index 4013962c..2f4cce9d 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -4,7 +4,7 @@ description: A guide to contributing to our open source projects. icon: fab fa-github --- -Our projects on Python Discord are open source and [available on Github](https://github.com/python-discord). If you would like to contribute, consider one of the following projects: +Our projects on Python Discord are open source and [available on GitHub](https://github.com/python-discord). If you would like to contribute, consider one of the following projects:
      @@ -91,14 +91,6 @@ Our projects on Python Discord are open source and [available on Github](https:/
      -If you don't understand anything or need clarification, feel free to ask any staff member with the **@PyDis Core Developers** role in the server. We're always happy to help! - -### Useful Resources - -[Guidelines](./contributing-guidelines/) - General guidelines you should follow when contributing to our projects.
      -[Style Guide](./style-guide/) - Information regarding the code styles you should follow when working on our projects.
      -[Review Guide](../code-reviews-primer/) - A guide to get you started on doing code reviews. - ## Contributors Community We are very happy to have many members in our community that contribute to [our open source projects](https://github.com/python-discord/). Whether it's writing code, reviewing pull requests, or contributing graphics for our events, it’s great to see so many people being motivated to help out. @@ -114,3 +106,48 @@ As it’s difficult to precisely quantify contributions, we’ve come up with th - The member has a positive influence in our contributors subcommunity. The role will be assigned at the discretion of the Admin Team in consultation with the Core Developers Team. + + +# How do I start contributing? + Completing these steps will have you ready to make your first contribution. If you've already been using Git or GitHub feel free to skip those steps, but please make sure to read about the PyDis contributing process and ettiquette. If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides. + + + +### Fork the repo + GitHub is a website based on the Git version control system that stores project files in the cloud. The people working on the project can use GitHub as a central place for sending their changes, getting their teammates' changes, and communicating with each other. Forking the repository that you want to work on will create a copy under your own GitHub account. You'll make your changes to this copy, then later we can bring them back to the PyDis repository. + + [Check out our guide on forking a GitHub repo](./forking-repository/) + +### Clone the repo + Now that you have your own fork you could make changes to it directly on GitHub, but that's not a convenient way to write code. Instead you can use Git to clone the repo to your local machine, commit changes to it there, then push those changes to GitHub. + + [Check out our guide on forking a GitHub repo](./forking-repository/) + +### Set up the project + You have the source code on your local computer, but how do you actually run it? + + [Sir Lancebot](./sir-lancebot/) + + [Python Bot](./bot/) + + [Site](./site/) + +### Ettiquette + [Guidelines](./contributing-guidelines/) +### Read the style guide + [Style Guide](./style-guide/) + +### Open a pull request + +### The review process + [Review Guide](../code-reviews-primer/) +### Create an issue + + +### Learn the basics of Git + Git is a *Version Control System*, software for carefully tracking changes to the files in a project. Git allows the same project to be worked on by people in different places. You can make changes to your local code and then distribute those changes to the other people working on the project. + + [Check out these resources to get started using Git](./working-with-git/) + + +If you don't understand anything or need clarification, feel free to ask any staff member with the **@PyDis Core Developers** role in the server. We're always happy to help! -- cgit v1.2.3 From 92b1f701b3d1b159b3353db954ce06fe631e56bc Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 13 Mar 2022 16:27:45 -0400 Subject: Move Contributors role info to the Roles page --- .../resources/guides/pydis-guides/contributing.md | 17 ----------------- pydis_site/apps/content/resources/server-info/roles.md | 8 ++++++-- 2 files changed, 6 insertions(+), 19 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index 2f4cce9d..596afb53 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -91,23 +91,6 @@ Our projects on Python Discord are open source and [available on GitHub](https:/
      -## Contributors Community -We are very happy to have many members in our community that contribute to [our open source projects](https://github.com/python-discord/). -Whether it's writing code, reviewing pull requests, or contributing graphics for our events, it’s great to see so many people being motivated to help out. -As a token of our appreciation, those who have made significant contributions to our projects will receive a special **@Contributors** role on our server that makes them stand out from other members. -That way, they can also serve as guides to others who are looking to start contributing to our open source projects or open source in general. - -#### Guidelines for the @Contributors Role - -One question we get a lot is what the requirements for the **@Contributors** role are. -As it’s difficult to precisely quantify contributions, we’ve come up with the following guidelines for the role: - -- The member has made several significant contributions to our projects. -- The member has a positive influence in our contributors subcommunity. - -The role will be assigned at the discretion of the Admin Team in consultation with the Core Developers Team. - - # How do I start contributing? Completing these steps will have you ready to make your first contribution. If you've already been using Git or GitHub feel free to skip those steps, but please make sure to read about the PyDis contributing process and ettiquette. If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides. diff --git a/pydis_site/apps/content/resources/server-info/roles.md b/pydis_site/apps/content/resources/server-info/roles.md index 716f5b1e..d9e0af15 100644 --- a/pydis_site/apps/content/resources/server-info/roles.md +++ b/pydis_site/apps/content/resources/server-info/roles.md @@ -28,8 +28,12 @@ There are multiple requirements listed there for getting the role. This includes writing pull requests for open issues, and also for reviewing open pull requests (**we really need reviewers!**) **How to get it:** Contribute to the projects! -There is no minimum requirements, but the role is **not** assigned for every single contribution. -Read more about this in the [Guidelines for the Contributors Role](/pages/contributing/#guidelines-for-the-contributors-role) on the Contributing page. +It’s difficult to precisely quantify contributions, but we’ve come up with the following guidelines for the role: + +- The member has made several significant contributions to our projects. +- The member has a positive influence in our contributors subcommunity. + +The role will be assigned at the discretion of the Admin Team in consultation with the Core Developers Team. Check out our [walkthrough](/pages/contributing/) to get started contributing. --- -- cgit v1.2.3 From 0de454f00fd7de1991a68078eea99a3b8e7004b1 Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 13 Mar 2022 18:11:16 -0400 Subject: Remove forking and cloning sections in set-up guides --- .../guides/pydis-guides/contributing/bot.md | 27 ---------------------- .../pydis-guides/contributing/sir-lancebot.md | 19 --------------- .../guides/pydis-guides/contributing/site.md | 12 ---------- .../pydis-guides/contributing/working-with-git.md | 6 +++-- 4 files changed, 4 insertions(+), 60 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 2aa10aa3..0f783ef6 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -7,33 +7,6 @@ toc: 3 The purpose of this guide is to get you a running local version of [the Python bot](https://github.com/python-discord/bot). This page will focus on the quickest steps one can take, with mentions of alternatives afterwards. -### Clone The Repository -First things first, to run the bot's code and make changes to it, you need a local version of it (on your computer). - -
      - - -
      -
      - -You will need to create a fork of [the project](https://github.com/python-discord/bot), and clone the fork. -Once this is done, you will have completed the first step towards having a running version of the bot. - -#### Working on the Repository Directly -If you are a member of the organisation (a member of [this list](https://github.com/orgs/python-discord/people), or in our particular case, server staff), you can clone the project repository without creating a fork, and work on a feature branch instead. - --- ### Set Up a Test Server 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 e3cd8f0c..6e5a9199 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 @@ -41,16 +41,6 @@ The requirements for Docker are: * This is only a required step for linux. Docker comes bundled with docker-compose on Mac OS and Windows. --- - -# Fork the Project -You will need your own remote (online) copy of the project repository, known as a *fork*. - -- [**Learn how to create a fork of the repository here.**](../forking-repository) - -You will do all your work in the fork rather than directly in the main repository. - ---- - # Development Environment 1. Once you have your fork, you will need to [**clone the repository to your computer**](../cloning-repository). 2. After cloning, proceed to [**install the project's dependencies**](../installing-project-dependencies). (This is not required if using Docker) @@ -121,13 +111,4 @@ After installing project dependencies use the poetry command `poetry run task st $ poetry run task start ``` ---- - -# Working with Git -Now that you have everything setup, it is finally time to make changes to the bot! If you have not yet [read the contributing guidelines](https://github.com/python-discord/sir-lancebot/blob/main/CONTRIBUTING.md), now is a good time. Contributions that do not adhere to the guidelines may be rejected. - -Notably, version control of our projects is done using Git and Github. It can be intimidating at first, so feel free to ask for any help in the server. - -[**Click here to see the basic Git workflow when contributing to one of our projects.**](../working-with-git/) - Have fun! diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md index f2c3bd95..7eda027a 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md @@ -26,18 +26,6 @@ Without Docker: - [PostgreSQL](https://www.postgresql.org/download/) - Note that if you wish, the webserver can run on the host and still use Docker for PostgreSQL. ---- -# Fork the project - -You will need access to a copy of the git repository of your own that will allow you to edit the code and push your commits to. -Creating a copy of a repository under your own account is called a _fork_. - -- [Learn how to create a fork of the repository here.](../forking-repository/) - -This is where all your changes and commits will be pushed to, and from where your PRs will originate from. - -For any Core Developers, since you have write permissions already to the original repository, you can just create a feature branch to push your commits to instead. - --- # Development environment diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/working-with-git.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/working-with-git.md index 26c89b56..59c57859 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/working-with-git.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/working-with-git.md @@ -19,5 +19,7 @@ Below are links to regular workflows for working with Git using PyCharm or the C **Resources to learn Git** * [The Git Book](https://git-scm.com/book) -* [Corey Schafer's Youtube Tutorials](https://www.youtube.com/watch?v=HVsySz-h9r4&list=PL-osiE80TeTuRUfjRe54Eea17-YfnOOAx) -* [GitHub Git Resources Portal](https://try.github.io/) +* [Corey Schafer's YouTube tutorials](https://www.youtube.com/watch?v=HVsySz-h9r4&list=PL-osiE80TeTuRUfjRe54Eea17-YfnOOAx) +* [GitHub Git resources portal](https://try.github.io/) +* [Git cheatsheet](https://education.github.com/git-cheat-sheet-education.pdf) +* [Learn Git branching](https://learngitbranching.js.org) -- cgit v1.2.3 From 35081bc351bde2ee338a855d33d719d689930e1f Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Mon, 14 Mar 2022 22:49:58 -0500 Subject: Migrate VPS services pin by Python bot to site --- .../resources/guides/python-guides/vps-services.md | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/vps-services.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md new file mode 100644 index 00000000..c153e876 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md @@ -0,0 +1,25 @@ +--- +title: VPS Services +description: On different VPS services +--- +If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS). +This is a list of VPS services that are sufficient for running Discord bots. + +* https://www.scaleway.com/ + * Based in Europe. +* https://www.digitalocean.com/ + * US-based. + * Considered by many to be the gold standard. + * Locations available across the world. +* https://www.ovh.co.uk/ + * France and Canadian locations available. +* https://www.time4vps.eu/ + * Seemingly based in Lithuania. +* https://www.linode.com/ + * Cheap VPS. +* https://www.vultr.com/ + * US-based, DigitalOcean-like. +* https://galaxygate.net/ + * A reliable, affordable, and trusted host. + +There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi (any model, except perhaps one of the particularly less powerful ones). \ No newline at end of file -- cgit v1.2.3 From 831f56f71d76741276d13e20954f46088e754d18 Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Mon, 14 Mar 2022 23:07:45 -0500 Subject: Fix end of file --- pydis_site/apps/content/resources/guides/python-guides/vps-services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md index c153e876..02feb8e6 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md +++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md @@ -22,4 +22,4 @@ This is a list of VPS services that are sufficient for running Discord bots. * https://galaxygate.net/ * A reliable, affordable, and trusted host. -There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi (any model, except perhaps one of the particularly less powerful ones). \ No newline at end of file +There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi (any model, except perhaps one of the particularly less powerful ones). -- cgit v1.2.3 From 30b7b4204b7e4b711960c952cccc15f667e2252f Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 19 Feb 2022 17:57:18 +0000 Subject: Move FilterList imports down so they're sorted --- pydis_site/apps/api/models/__init__.py | 2 +- pydis_site/apps/api/models/bot/__init__.py | 2 +- pydis_site/apps/api/urls.py | 16 ++++++++-------- pydis_site/apps/api/viewsets/__init__.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index 4f616986..e83473c9 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -1,9 +1,9 @@ # flake8: noqa from .bot import ( - FilterList, BotSetting, 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 ec0e701c..64676fdb 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -1,8 +1,8 @@ # flake8: noqa -from .filter_list import FilterList from .bot_setting import BotSetting 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/urls.py b/pydis_site/apps/api/urls.py index 7c55fc92..6b881fac 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -21,8 +21,12 @@ from .viewsets import ( # https://www.django-rest-framework.org/api-guide/routers/#defaultrouter bot_router = DefaultRouter(trailing_slash=False) bot_router.register( - 'filter-lists', - FilterListViewSet + "aoc-account-links", + AocAccountLinkViewSet +) +bot_router.register( + "aoc-completionist-blocks", + AocCompletionistBlockViewSet ) bot_router.register( 'bot-settings', @@ -37,12 +41,8 @@ bot_router.register( DocumentationLinkViewSet ) bot_router.register( - "aoc-account-links", - AocAccountLinkViewSet -) -bot_router.register( - "aoc-completionist-blocks", - AocCompletionistBlockViewSet + 'filter-lists', + FilterListViewSet ) bot_router.register( 'infractions', diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index 5fc1d64f..a62a9c01 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -1,9 +1,9 @@ # flake8: noqa from .bot import ( - FilterListViewSet, BotSettingViewSet, DeletedMessageViewSet, DocumentationLinkViewSet, + FilterListViewSet, InfractionViewSet, NominationViewSet, OffensiveMessageViewSet, -- cgit v1.2.3 From 0aed5f7913e7ce268ddb56127f84a5386ede5739 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 19 Feb 2022 17:59:26 +0000 Subject: Add support for BumpedThreads to be stored in site Following our move to use Redis as just a cache, this PR allows the site to store a list of threads that need to be bumped. The bot will interact with this within the ThreadBumper cog. --- .../apps/api/migrations/0081_bumpedthread.py | 22 ++++++++ pydis_site/apps/api/models/__init__.py | 1 + pydis_site/apps/api/models/bot/__init__.py | 1 + pydis_site/apps/api/models/bot/bumped_thread.py | 22 ++++++++ pydis_site/apps/api/serializers.py | 11 ++++ pydis_site/apps/api/urls.py | 5 ++ pydis_site/apps/api/viewsets/__init__.py | 1 + pydis_site/apps/api/viewsets/bot/__init__.py | 1 + pydis_site/apps/api/viewsets/bot/bumped_thread.py | 65 ++++++++++++++++++++++ 9 files changed, 129 insertions(+) create mode 100644 pydis_site/apps/api/migrations/0081_bumpedthread.py create mode 100644 pydis_site/apps/api/models/bot/bumped_thread.py create mode 100644 pydis_site/apps/api/viewsets/bot/bumped_thread.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0081_bumpedthread.py b/pydis_site/apps/api/migrations/0081_bumpedthread.py new file mode 100644 index 00000000..03e66cc1 --- /dev/null +++ b/pydis_site/apps/api/migrations/0081_bumpedthread.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1.14 on 2022-02-19 16:26 + +import django.core.validators +from django.db import migrations, models +import pydis_site.apps.api.models.mixins + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0080_add_aoc_tables'), + ] + + operations = [ + migrations.CreateModel( + name='BumpedThread', + fields=[ + ('thread_id', models.BigIntegerField(help_text='The thread ID that should be bumped.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Thread IDs cannot be negative.')], verbose_name='Thread ID')), + ], + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), + ), + ] diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index e83473c9..a197e988 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -1,6 +1,7 @@ # flake8: noqa from .bot import ( BotSetting, + BumpedThread, DocumentationLink, DeletedMessage, FilterList, diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index 64676fdb..013bb85e 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -1,5 +1,6 @@ # flake8: noqa 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 diff --git a/pydis_site/apps/api/models/bot/bumped_thread.py b/pydis_site/apps/api/models/bot/bumped_thread.py new file mode 100644 index 00000000..cdf9a950 --- /dev/null +++ b/pydis_site/apps/api/models/bot/bumped_thread.py @@ -0,0 +1,22 @@ +from django.core.validators import MinValueValidator +from django.db import models + +from pydis_site.apps.api.models.mixins import ModelReprMixin + + +class BumpedThread(ModelReprMixin, models.Model): + """A list of thread IDs to be bumped.""" + + thread_id = models.BigIntegerField( + primary_key=True, + help_text=( + "The thread ID that should be bumped." + ), + validators=( + MinValueValidator( + limit_value=0, + message="Thread IDs cannot be negative." + ), + ), + verbose_name="Thread ID", + ) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index c97f7dba..b9e06081 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -16,6 +16,7 @@ from .models import ( AocAccountLink, AocCompletionistBlock, BotSetting, + BumpedThread, DeletedMessage, DocumentationLink, FilterList, @@ -41,6 +42,16 @@ class BotSettingSerializer(ModelSerializer): fields = ('name', 'data') +class BumpedThreadSerializer(ModelSerializer): + """A class providing (de-)serialization of `BumpedThread` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = BumpedThread + fields = ('thread_id',) + + class DeletedMessageSerializer(ModelSerializer): """ A class providing (de-)serialization of `DeletedMessage` instances. diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index 6b881fac..1e564b29 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -6,6 +6,7 @@ from .viewsets import ( AocAccountLinkViewSet, AocCompletionistBlockViewSet, BotSettingViewSet, + BumpedThreadViewSet, DeletedMessageViewSet, DocumentationLinkViewSet, FilterListViewSet, @@ -32,6 +33,10 @@ bot_router.register( 'bot-settings', BotSettingViewSet ) +bot_router.register( + 'bumped-threads', + BumpedThreadViewSet +) bot_router.register( 'deleted-messages', DeletedMessageViewSet diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index a62a9c01..ec52416a 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -1,6 +1,7 @@ # flake8: noqa from .bot import ( BotSettingViewSet, + BumpedThreadViewSet, DeletedMessageViewSet, DocumentationLinkViewSet, FilterListViewSet, diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py index f1d84729..262aa59f 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -1,6 +1,7 @@ # flake8: noqa from .filter_list import FilterListViewSet from .bot_setting import BotSettingViewSet +from .bumped_thread import BumpedThreadViewSet from .deleted_message import DeletedMessageViewSet from .documentation_link import DocumentationLinkViewSet from .infraction import InfractionViewSet diff --git a/pydis_site/apps/api/viewsets/bot/bumped_thread.py b/pydis_site/apps/api/viewsets/bot/bumped_thread.py new file mode 100644 index 00000000..6594ac6e --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/bumped_thread.py @@ -0,0 +1,65 @@ +from rest_framework.mixins import ( + CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin +) +from rest_framework.viewsets import GenericViewSet + +from pydis_site.apps.api.models.bot import BumpedThread +from pydis_site.apps.api.serializers import BumpedThreadSerializer + + +class BumpedThreadViewSet( + GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin +): + """ + View providing CRUD (Minus the U) operations on threads to be bumped. + + ## Routes + ### GET /bot/bumped-threads + Returns all BumpedThread items in the database. + + #### Response format + >>> [ + ... { + ... 'thread_id': "941705627405811793", + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + - 401: returned if unauthenticated + + ### GET /bot/bumped-threads/ + Returns a specific BumpedThread item from the database. + + #### Response format + >>> { + ... 'thread_id': "941705627405811793", + ... } + + #### Status codes + - 200: returned on success + - 404: returned if a BumpedThread with the given thread_id was not found. + + ### POST /bot/bumped-threads + Adds a single BumpedThread item to the database. + + #### Request body + >>> { + ... 'thread_id': int, + ... } + + #### Status codes + - 201: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/bumped-threads/ + Deletes the BumpedThread item with the given `thread_id`. + + #### Status codes + - 204: returned on success + - 404: if a BumpedThread with the given `thread_id` does not exist + """ + + serializer_class = BumpedThreadSerializer + queryset = BumpedThread.objects.all() -- cgit v1.2.3 From 3e9557056c06a39c077b76d718eb35b99a365711 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 16 Mar 2022 21:46:46 +0000 Subject: Only return list of ints when retrieving all BumpedThreads --- pydis_site/apps/api/serializers.py | 17 +++++++++++++++++ pydis_site/apps/api/viewsets/bot/bumped_thread.py | 7 +------ 2 files changed, 18 insertions(+), 6 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index b9e06081..dfdda915 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -42,12 +42,29 @@ class BotSettingSerializer(ModelSerializer): fields = ('name', 'data') +class ListBumpedThreadSerializer(ListSerializer): + """Custom ListSerializer to override to_representation() when list views are triggered.""" + + def to_representation(self, objects: list[BumpedThread]) -> int: + """ + Used by the `ListModelMixin` to return just the list of bumped thread ids. + + We want to only return the thread_id attribute, hence it is unnecessary + to create a nested dictionary. + + Additionally, this allows bumped thread routes to simply return an + array of thread_id ints instead of objects, saving on bandwidth. + """ + return [obj.thread_id for obj in objects] + + class BumpedThreadSerializer(ModelSerializer): """A class providing (de-)serialization of `BumpedThread` instances.""" class Meta: """Metadata defined for the Django REST Framework.""" + list_serializer_class = ListBumpedThreadSerializer model = BumpedThread fields = ('thread_id',) diff --git a/pydis_site/apps/api/viewsets/bot/bumped_thread.py b/pydis_site/apps/api/viewsets/bot/bumped_thread.py index 6594ac6e..0972379b 100644 --- a/pydis_site/apps/api/viewsets/bot/bumped_thread.py +++ b/pydis_site/apps/api/viewsets/bot/bumped_thread.py @@ -18,12 +18,7 @@ class BumpedThreadViewSet( Returns all BumpedThread items in the database. #### Response format - >>> [ - ... { - ... 'thread_id': "941705627405811793", - ... }, - ... ... - ... ] + >>> list[int] #### Status codes - 200: returned on success -- cgit v1.2.3 From a0180619e77d884aeedb1a89748c0115c8ec8c56 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 16 Mar 2022 21:47:57 +0000 Subject: Don't return the BumpedThread object when retrieving single We only need to check for existence, so sending the full object isn't needed. --- pydis_site/apps/api/viewsets/bot/bumped_thread.py | 24 ++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/viewsets/bot/bumped_thread.py b/pydis_site/apps/api/viewsets/bot/bumped_thread.py index 0972379b..9d77bb6b 100644 --- a/pydis_site/apps/api/viewsets/bot/bumped_thread.py +++ b/pydis_site/apps/api/viewsets/bot/bumped_thread.py @@ -1,6 +1,8 @@ from rest_framework.mixins import ( - CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin + CreateModelMixin, DestroyModelMixin, ListModelMixin ) +from rest_framework.request import Request +from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet from pydis_site.apps.api.models.bot import BumpedThread @@ -8,7 +10,7 @@ from pydis_site.apps.api.serializers import BumpedThreadSerializer class BumpedThreadViewSet( - GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin + GenericViewSet, CreateModelMixin, DestroyModelMixin, ListModelMixin ): """ View providing CRUD (Minus the U) operations on threads to be bumped. @@ -25,15 +27,10 @@ class BumpedThreadViewSet( - 401: returned if unauthenticated ### GET /bot/bumped-threads/ - Returns a specific BumpedThread item from the database. - - #### Response format - >>> { - ... 'thread_id': "941705627405811793", - ... } + Returns whether a specific BumpedThread exists in the database. #### Status codes - - 200: returned on success + - 204: returned on success - 404: returned if a BumpedThread with the given thread_id was not found. ### POST /bot/bumped-threads @@ -58,3 +55,12 @@ class BumpedThreadViewSet( serializer_class = BumpedThreadSerializer queryset = BumpedThread.objects.all() + + def retrieve(self, request: Request, *args, **kwargs) -> Response: + """ + DRF method for checking if the given BumpedThread exists. + + Called by the Django Rest Framework in response to the corresponding HTTP request. + """ + self.get_object() + return Response(status=204) -- cgit v1.2.3 From cb52e60a631041a08edb213f41cca8befba4bf7e Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 16 Mar 2022 23:17:46 +0000 Subject: Add tests for custom BumpedThread impl --- pydis_site/apps/api/tests/test_bumped_threads.py | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 pydis_site/apps/api/tests/test_bumped_threads.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_bumped_threads.py b/pydis_site/apps/api/tests/test_bumped_threads.py new file mode 100644 index 00000000..316e3f0b --- /dev/null +++ b/pydis_site/apps/api/tests/test_bumped_threads.py @@ -0,0 +1,63 @@ +from django.urls import reverse + +from .base import AuthenticatedAPITestCase +from ..models import BumpedThread + + +class UnauthedBumpedThreadAPITests(AuthenticatedAPITestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_detail_lookup_returns_401(self): + url = reverse('api:bot:bumpedthread-detail', args=(1,)) + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_list_returns_401(self): + url = reverse('api:bot:bumpedthread-list') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_create_returns_401(self): + url = reverse('api:bot:bumpedthread-list') + response = self.client.post(url, {"thread_id": 3}) + + self.assertEqual(response.status_code, 401) + + def test_delete_returns_401(self): + url = reverse('api:bot:bumpedthread-detail', args=(1,)) + response = self.client.delete(url) + + self.assertEqual(response.status_code, 401) + + +class BumpedThreadAPITests(AuthenticatedAPITestCase): + @classmethod + def setUpTestData(cls): + cls.thread1 = BumpedThread.objects.create( + thread_id=1234, + ) + + def test_returns_bumped_threads_as_flat_list(self): + url = reverse('api:bot:bumpedthread-list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), [1234]) + + def test_returns_204_for_existing_data(self): + url = reverse('api:bot:bumpedthread-detail', args=(1234,)) + + response = self.client.get(url) + self.assertEqual(response.status_code, 204) + self.assertEqual(response.content, b"") + + def test_returns_404_for_non_existing_data(self): + url = reverse('api:bot:bumpedthread-detail', args=(42,)) + + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json(), {"detail": "Not found."}) -- cgit v1.2.3 From 429d98a5349b55e63f93e6192d8b2b35262dc60b Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Fri, 18 Mar 2022 15:11:34 +0000 Subject: fixup: don't use "We" in docstring Co-authored-by: Mark <1515135+MarkKoz@users.noreply.github.com> --- pydis_site/apps/api/serializers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index dfdda915..e53ccffa 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -49,8 +49,7 @@ class ListBumpedThreadSerializer(ListSerializer): """ Used by the `ListModelMixin` to return just the list of bumped thread ids. - We want to only return the thread_id attribute, hence it is unnecessary - to create a nested dictionary. + Only the thread_id field is useful, hence it is unnecessary to create a nested dictionary. Additionally, this allows bumped thread routes to simply return an array of thread_id ints instead of objects, saving on bandwidth. -- cgit v1.2.3 From fa78e75321966e6c4180c9e844d0522a8966d155 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Fri, 18 Mar 2022 23:00:08 +0400 Subject: Add custom help command --- .../guides/python-guides/discordpy_help_command.md | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md b/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md new file mode 100644 index 00000000..f2c672af --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md @@ -0,0 +1,34 @@ +# Custom Help Command + + + +### First, a [basic walkthrough](https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96) by Stella#2000 on subclassing the HelpCommand will provide some foundational knowledge required before attempting a more customizable help command. + +--- + +## Custom Subclass of Help Command +### If this does not fit your needs and you require a more customizable help command, you can subclass HelpCommand and add individual command details. Below is a basic demonstration: + +```python +class MyHelpCommand(commands.HelpCommand): + async def command_callback(self, ctx, *,command=None): + if command: + await ctx.send(f"This is the help page for the command {command} ") + else: + await ctx.send("This is the front page for the bots help command") +bot.help_command = MyHelpCommand() +``` +--- +### You can handle when there is no command and make a fancy embed; a page that describes the bot and shows a list of commands is usually ideal here, however if a command is passed in, you can get display detailed information of the command with reference from the following documentations below: + +* [Get the command object](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot.get_command) + +* [Get the command name](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Command.name) + +* [Get the command aliases](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Command.aliases) + +* [Get the command brief](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Command.brief) + +* [Get the command usage](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Command.usage) + +* Get the command cooldown - `command_object._buckets._cooldown.per.` -- cgit v1.2.3 From 939aa26d0b2788b84b35e434cfefa287b28b3428 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Sat, 19 Mar 2022 17:03:55 +0400 Subject: Add custom help command --- .../resources/guides/python-guides/discordpy_help_command.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md b/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md index f2c672af..58d75918 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md @@ -1,13 +1,17 @@ -# Custom Help Command +--- +title: Custom Help Command +description: "Overwrite discord.py's help command to implement custom functionality" +--- +# Custom Help Command -### First, a [basic walkthrough](https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96) by Stella#2000 on subclassing the HelpCommand will provide some foundational knowledge required before attempting a more customizable help command. + First, a [basic walkthrough](https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96) by Stella#2000 on subclassing the HelpCommand will provide some foundational knowledge required before attempting a more customizable help command. --- ## Custom Subclass of Help Command -### If this does not fit your needs and you require a more customizable help command, you can subclass HelpCommand and add individual command details. Below is a basic demonstration: +If this does not fit your needs and you require a more customizable help command, you can subclass HelpCommand and add individual command details. Below is a basic demonstration: ```python class MyHelpCommand(commands.HelpCommand): @@ -19,7 +23,7 @@ class MyHelpCommand(commands.HelpCommand): bot.help_command = MyHelpCommand() ``` --- -### You can handle when there is no command and make a fancy embed; a page that describes the bot and shows a list of commands is usually ideal here, however if a command is passed in, you can get display detailed information of the command with reference from the following documentations below: +You can handle when there is no command and make a fancy embed; a page that describes the bot and shows a list of commands is usually ideal here, however if a command is passed in, you can get display detailed information of the command with reference from the following documentations below: * [Get the command object](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot.get_command) -- cgit v1.2.3 From 36b43f94cb1f8c622914048696efec8ccdeb608f Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sat, 19 Mar 2022 23:42:45 -0400 Subject: Add guide for pull requests and reviews This covers the GitHub UI for opening a pull request, getting it reviewed, and draft PRs. --- .../pydis-guides/contributing/pull-requests.md | 41 +++++++++++++++++++++ .../images/content/contributing/pull_request.png | Bin 0 -> 10190 bytes 2 files changed, 41 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md create mode 100644 pydis_site/static/images/content/contributing/pull_request.png (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md new file mode 100644 index 00000000..f7dee491 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md @@ -0,0 +1,41 @@ +--- +title: Pull Requests +description: A guide for opening pull requests. +--- + +As stated in our [Contributing Guidelines](../contributing-guidelines/), do not open a pull request if you aren't assigned to an approved issue. You can check out our [Issues Guide](../issues/) for help with opening an issue or getting assigned to an existing one. +{: .notification .is-warning } + + +Before opening a pull request you should have: + +1. Committed your changes to your local repository +2. [Linted](../contributing-guidelines/#linting-and-pre-commit) your code +3. Tested your changes +4. Pushed the branch to your fork of the project on GitHub + +## Opening a Pull Request + +Navigate to your fork on GitHub and make sure you're on the branch with your changes. Click on `Contribute` and then `Open pull request`. + +![Pull Request UI](/static/images/content/contributing/pull_request.png) + +In the page that it opened, write an overview of the changes you made and why. This should explain how you resolved the issue that spawned this PR and highlight any differences from the proposed implementation. You should also [link the issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). + +At this stage you can also request reviews from individual contributors. If someone showed interest in the issue or has specific knowledge about it, they may be a good reviewer. It isn't necessary to request your reviewers; someone will review your PR either way. + +## The Review Process + +Before your changes are merged, your PR needs to be reviewed by other contributors. They will read the issue and your description of your PR, look at your code, test it, and then leave comments on the PR if they find any problems, possibly with suggested changes. Sometimes this can feel intrusive or insulting, but remember that the reviewers are there to help you make your code better. + +#### If the PR is already open, how do I make changes to it? + +A pull request is between a source branch and a target branch. Updating the source branch with new commits will automatically update the PR to include those commits; they'll even show up in the comment thread of the PR. Sometimes for small changes the reviewer will even write the suggested code themself, in which case you can simply accept them with the click of a button. + +If you truly disagree with a reviewer's suggestion, leave a reply in the thread explaining why or proposing an alternative change. Also feel free to ask questions if you want clarification about suggested changes or just want to discuss them further. + +## Draft Pull Requests + +GitHub [provides a PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a draft when opening it. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review. This is helpful when you want people to see the changes you're making before you're ready for the final pull request. + +This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title. diff --git a/pydis_site/static/images/content/contributing/pull_request.png b/pydis_site/static/images/content/contributing/pull_request.png new file mode 100644 index 00000000..87b7ffbe Binary files /dev/null and b/pydis_site/static/images/content/contributing/pull_request.png differ -- cgit v1.2.3 From d198b3e02236e65bc2d8fceb9d7d8776dc306b87 Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 20 Mar 2022 14:56:05 -0400 Subject: Split up Supplemental Info and touch up Contrib Guidelines Created new pages: * Linting * Logging * Writing Good Commit Messages Moved Draft PR section to new pull requests guide Moved type hinting section to style guide --- .../contributing/contributing-guidelines.md | 25 +++--- .../contributing-guidelines/commit-messages.md | 15 ++++ .../supplemental-information.md | 99 ---------------------- .../guides/pydis-guides/contributing/linting.md | 14 +++ .../guides/pydis-guides/contributing/logging.md | 31 +++++++ .../pydis-guides/contributing/pull-requests.md | 2 +- .../pydis-guides/contributing/style-guide.md | 24 +++--- 7 files changed, 82 insertions(+), 128 deletions(-) create mode 100644 pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/commit-messages.md delete mode 100644 pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/supplemental-information.md create mode 100644 pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md create mode 100644 pydis_site/apps/content/resources/guides/pydis-guides/contributing/logging.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md index de1777f2..f13b05be 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md @@ -4,22 +4,15 @@ description: Guidelines to adhere to when contributing to our projects. --- Thank you for your interest in our projects! +This page contains the golden rules to follow when contributing. If you have questions about how to get started contributing, check out our [in-depth walkthrough](../../contributing/). -If you are interested in contributing, **this page contains the golden rules to follow when contributing.** -Supplemental information [can be found here](./supplemental-information/). -Do note that failing to comply with our guidelines may lead to a rejection of the contribution. - -If you are confused by any of these rules, feel free to ask us in the `#dev-contrib` channel in our [Discord server.](https://discord.gg/python) - -# The Golden Rules of Contributing - -1. **Lint before you push.** We have simple but strict style rules that are enforced through linting. -You must always lint your code before committing or pushing. -[Using tools](./supplemental-information/#linting-and-pre-commit) such as `flake8` and `pre-commit` can make this easier. -Make sure to follow our [style guide](../style-guide/) when contributing. +1. **Lint before you push.** +We have simple but strict style rules that are enforced through linting. +[Set up a pre-commit hook](../linting/) to lint your code when you commit it. +Not all of the style rules are enforced by linting, so make sure to read the [style guide](../style-guide/) as well. 2. **Make great commits.** Great commits should be atomic, with a commit message explaining what and why. -More on that can be found in [this section](./supplemental-information/#writing-good-commit-messages). +Check out [Writing Good Commit Messages](/commit-messages) for details. 3. **Do not open a pull request if you aren't assigned to the issue.** If someone is already working on it, consider offering to collaborate with that person. 4. **Use assets licensed for public use.** @@ -28,4 +21,8 @@ Whenever the assets are images, audio or even code, they must have a license com We aim to foster a welcoming and friendly environment on our open source projects. We take violations of our Code of Conduct very seriously, and may respond with moderator action. -Welcome to our projects! +
      + +Failing to comply with our guidelines may lead to a rejection of the contribution. +If you have questions about any of the rules, feel free to ask us in the `#dev-contrib` channel in our [Discord server](https://discord.gg/python). +{: .notification .is-warning } diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/commit-messages.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/commit-messages.md new file mode 100644 index 00000000..ba476b65 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/commit-messages.md @@ -0,0 +1,15 @@ +--- +title: Writing Good Commit Messages +description: Information about logging in our projects. +--- + +A well-structured git log is key to a project's maintainability; it provides insight into when and *why* things were done for future maintainers of the project. + +Commits should be as narrow in scope as possible. +Commits that span hundreds of lines across multiple unrelated functions and/or files are very hard for maintainers to follow. +After about a week they'll probably be hard for you to follow, too. + +Please also avoid making minor commits for fixing typos or linting errors. +[Don’t forget to lint before you push!](https://soundcloud.com/lemonsaurusrex/lint-before-you-push) + +A more in-depth guide to writing great commit messages can be found in Chris Beam's [How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/). diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/supplemental-information.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/supplemental-information.md deleted file mode 100644 index e64e4fc6..00000000 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/supplemental-information.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -title: Supplemental Information -description: Additional information related to our contributing guidelines. ---- - -This page contains additional information concerning a specific part of our development pipeline. - -## Writing Good Commit Messages - -A well-structured git log is key to a project's maintainability; it provides insight into when and *why* things were done for future maintainers of the project. - -Commits should be as narrow in scope as possible. -Commits that span hundreds of lines across multiple unrelated functions and/or files are very hard for maintainers to follow. -After about a week they'll probably be hard for you to follow, too. - -Please also avoid making minor commits for fixing typos or linting errors. -*[Don’t forget to lint before you push!](https://soundcloud.com/lemonsaurusrex/lint-before-you-push)* - -A more in-depth guide to writing great commit messages can be found in Chris Beam's *[How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/).* - -## Code Style - -All of our projects have a certain project-wide style that contributions should attempt to maintain consistency with. -During PR review, it's not unusual for style adjustments to be requested. - -[This page](../../style-guide/) will reference the differences between our projects and what is recommended by [PEP 8.](https://www.python.org/dev/peps/pep-0008/) - -## Linting and Pre-commit - -On most of our projects, we use `flake8` and `pre-commit` to ensure that the code style is consistent across the code base. - -Running `flake8` will warn you about any potential style errors in your contribution. -You must always check it **before pushing**. -Your commit will be rejected by the build server if it fails to lint. - -**Some style rules are not enforced by flake8. Make sure to read the [style guide](../../style-guide/).** - -`pre-commit` is a powerful tool that helps you automatically lint before you commit. -If the linter complains, the commit is aborted so that you can fix the linting errors before committing again. -That way, you never commit the problematic code in the first place! - -Please refer to the project-specific documentation to see how to setup and run those tools. -In most cases, you can install pre-commit using `poetry run task precommit`, and lint using `poetry run task lint`. - -## Type Hinting - -[PEP 484](https://www.python.org/dev/peps/pep-0484/) formally specifies type hints for Python functions, added to the Python Standard Library in version 3.5. -Type hints are recognized by most modern code editing tools and provide useful insight into both the input and output types of a function, preventing the user from having to go through the codebase to determine these types. - -For example: - -```python -import typing - -def foo(input_1: int, input_2: typing.Dict[str, str]) -> bool: - ... -``` - -This tells us that `foo` accepts an `int` and a `dict`, with `str` keys and values, and returns a `bool`. - -If the project is running Python 3.9 or above, you can use `dict` instead of `typing.Dict`. -See [PEP 585](https://www.python.org/dev/peps/pep-0585/) for more information. - -All function declarations should be type hinted in code contributed to the PyDis organization. - -## Logging - -Instead of using `print` statements for logging, we use the built-in [`logging`](https://docs.python.org/3/library/logging.html) module. -Here is an example usage: - -```python -import logging - -log = logging.getLogger(__name__) # Get a logger bound to the module name. -# This line is usually placed under the import statements at the top of the file. - -log.trace("This is a trace log.") -log.warning("BEEP! This is a warning.") -log.critical("It is about to go down!") -``` - -Print statements should be avoided when possible. -Our projects currently defines logging levels as follows, from lowest to highest severity: - -- **TRACE:** These events should be used to provide a *verbose* trace of every step of a complex process. This is essentially the `logging` equivalent of sprinkling `print` statements throughout the code. -- **Note:** This is a PyDis-implemented logging level. It may not be available on every project. -- **DEBUG:** These events should add context to what's happening in a development setup to make it easier to follow what's going while workig on a project. This is in the same vein as **TRACE** logging but at a much lower level of verbosity. -- **INFO:** These events are normal and don't need direct attention but are worth keeping track of in production, like checking which cogs were loaded during a start-up. -- **WARNING:** These events are out of the ordinary and should be fixed, but can cause a failure. -- **ERROR:** These events can cause a failure in a specific part of the application and require urgent attention. -- **CRITICAL:** These events can cause the whole application to fail and require immediate intervention. - -Any logging above the **INFO** level will trigger a [Sentry](https://sentry.io) issue and alert the Core Developer team. - -## Draft Pull Requests - -Github [provides a PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a Draft when opening it. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review. - -This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md new file mode 100644 index 00000000..48f1cafc --- /dev/null +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md @@ -0,0 +1,14 @@ +--- +title: Linting +description: A guide for linting and setting up pre-commit. +--- + +Your commit will be rejected by the build server if it fails to lint. +On most of our projects, we use `flake8` and `pre-commit` to ensure that the code style is consistent across the code base. + +`pre-commit` is a powerful tool that helps you automatically lint before you commit. +If the linter complains, the commit is aborted so that you can fix the linting errors before committing again. +That way, you never commit the problematic code in the first place! + +Please refer to the project-specific documentation to see how to setup and run those tools. +In most cases, you can install pre-commit using `poetry run task precommit`, and lint using `poetry run task lint`. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/logging.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/logging.md new file mode 100644 index 00000000..1291a7a4 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/logging.md @@ -0,0 +1,31 @@ +--- +title: Logging +description: Information about logging in our projects. +--- + +Instead of using `print` statements for logging, we use the built-in [`logging`](https://docs.python.org/3/library/logging.html) module. +Here is an example usage: + +```python +import logging + +log = logging.getLogger(__name__) # Get a logger bound to the module name. +# This line is usually placed under the import statements at the top of the file. + +log.trace("This is a trace log.") +log.warning("BEEP! This is a warning.") +log.critical("It is about to go down!") +``` + +Print statements should be avoided when possible. +Our projects currently defines logging levels as follows, from lowest to highest severity: + +- **TRACE:** These events should be used to provide a *verbose* trace of every step of a complex process. This is essentially the `logging` equivalent of sprinkling `print` statements throughout the code. +- **Note:** This is a PyDis-implemented logging level. It may not be available on every project. +- **DEBUG:** These events should add context to what's happening in a development setup to make it easier to follow what's going while workig on a project. This is in the same vein as **TRACE** logging but at a much lower level of verbosity. +- **INFO:** These events are normal and don't need direct attention but are worth keeping track of in production, like checking which cogs were loaded during a start-up. +- **WARNING:** These events are out of the ordinary and should be fixed, but can cause a failure. +- **ERROR:** These events can cause a failure in a specific part of the application and require urgent attention. +- **CRITICAL:** These events can cause the whole application to fail and require immediate intervention. + +Any logging above the **INFO** level will trigger a [Sentry](https://sentry.io) issue and alert the Core Developer team. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md index f7dee491..a9b6385e 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md @@ -10,7 +10,7 @@ As stated in our [Contributing Guidelines](../contributing-guidelines/), do not Before opening a pull request you should have: 1. Committed your changes to your local repository -2. [Linted](../contributing-guidelines/#linting-and-pre-commit) your code +2. [Linted](../linting/) your code 3. Tested your changes 4. Pushed the branch to your fork of the project on GitHub diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/style-guide.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/style-guide.md index f9962990..4dba45c8 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/style-guide.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/style-guide.md @@ -191,21 +191,17 @@ Present tense defines that the work being done is now, in the present, rather th **Use:** "Build an information embed."
      **Don't use:** "Built an information embed." or "Will build an information embed." -# Type Annotations -Functions are required to have type annotations as per the style defined in [PEP 484](https://www.python.org/dev/peps/pep-0484/). +# Type Hinting +Functions are required to have type annotations as per the style defined in [PEP 484](https://www.python.org/dev/peps/pep-0484/). Type hints are recognized by most modern code editing tools and provide useful insight into both the input and output types of a function, preventing the user from having to go through the codebase to determine these types. -A function without annotations might look like: -```py -def divide(a, b): - """Divide the two given arguments.""" - return a / b -``` - -With annotations, the arguments and the function are annotated with their respective types: -```py -def divide(a: int, b: int) -> float: - """Divide the two given arguments.""" - return a / b +A function with type hints looks like: +```python +def foo(input_1: int, input_2: dict[str, int]) -> bool: + ... ``` +This tells us that `foo` accepts an `int` and a `dict`, with `str` keys and `int` values, and returns a `bool`. In previous examples, we have purposely omitted annotations to keep focus on the specific points they represent. + +> **Note:** if the project is running Python 3.8 or below you have to use `typing.Dict` instead of `dict`, but our three main projects are all >=3.9. +> See [PEP 585](https://www.python.org/dev/peps/pep-0585/) for more information. -- cgit v1.2.3 From 79694efe7bb13a9d6791844e7a836b7f4350308d Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 20 Mar 2022 17:08:08 -0400 Subject: Write walkthrough --- .../resources/guides/pydis-guides/contributing.md | 60 +++++++++++++--------- 1 file changed, 36 insertions(+), 24 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index 596afb53..759ed1dc 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -92,45 +92,57 @@ Our projects on Python Discord are open source and [available on GitHub](https:/ # How do I start contributing? - Completing these steps will have you ready to make your first contribution. If you've already been using Git or GitHub feel free to skip those steps, but please make sure to read about the PyDis contributing process and ettiquette. If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides. + Unsure of what contributing to open source projects involves? Have questions about how to use GitHub? Just need to know about our contribution etiquette? Completing these steps will have you ready to make your first contribution. + Feel free to skip any steps you're already familiar with, but please make sure not to miss the [Contributing Guidelines](#5-read-our-contributing-guidelines). + If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides. -### Fork the repo - GitHub is a website based on the Git version control system that stores project files in the cloud. The people working on the project can use GitHub as a central place for sending their changes, getting their teammates' changes, and communicating with each other. Forking the repository that you want to work on will create a copy under your own GitHub account. You'll make your changes to this copy, then later we can bring them back to the PyDis repository. +### 1. Learn the basics of Git + Git is a _Version Control System_, software for carefully tracking changes to the files in a project. Git allows the same project to be worked on by people in different places. You can make changes to your local code and then distribute those changes to the other people working on the project. - [Check out our guide on forking a GitHub repo](./forking-repository/) + Check out these [**resources to get started using Git**](./working-with-git/). -### Clone the repo - Now that you have your own fork you could make changes to it directly on GitHub, but that's not a convenient way to write code. Instead you can use Git to clone the repo to your local machine, commit changes to it there, then push those changes to GitHub. +### 2. Fork the repo + GitHub is a website based on the Git version control system that stores project files in the cloud. The people working on the project can use GitHub as a central place for sending their changes, getting their teammates' changes, and communicating with each other. Forking the repository that you want to work on will create a copy under your own GitHub account. You'll make your changes to this copy, then later we can bring them back to the PyDis repository. - [Check out our guide on forking a GitHub repo](./forking-repository/) + Check out our [**guide on forking a GitHub repo**](./forking-repository/). -### Set up the project - You have the source code on your local computer, but how do you actually run it? +### 3. Clone the repo + Now that you have your own fork you need to be able to make changes to the code. You can clone the repo to your local machine, commit changes to it there, then push those changes to GitHub. - [Sir Lancebot](./sir-lancebot/) + Check out our [**guide on cloning a GitHub repo**](./cloning-repository/). - [Python Bot](./bot/) +### 4. Set up the project + You have the source code on your local computer, now how do you actually run it? We have detailed guides on setting up the environment for each of our projects: - [Site](./site/) + * [**Sir Lancebot**](./sir-lancebot/) -### Ettiquette - [Guidelines](./contributing-guidelines/) -### Read the style guide - [Style Guide](./style-guide/) + * [**Python Bot**](./bot/) -### Open a pull request + * [**Site**](./site/) -### The review process - [Review Guide](../code-reviews-primer/) -### Create an issue +### 5. Read our Contributing Guidelines + We have a few short rules that all contributors must follow. Make sure you read and follow them while working on our projects. + [**Contributing Guidelines**](./contributing-guidelines/). -### Learn the basics of Git - Git is a *Version Control System*, software for carefully tracking changes to the files in a project. Git allows the same project to be worked on by people in different places. You can make changes to your local code and then distribute those changes to the other people working on the project. + As mentioned in the Contributing Guidelines, we have a simple style guide for our projects based on PEP 8. Give it a read to keep your code consistent with the rest of the codebase. - [Check out these resources to get started using Git](./working-with-git/) + [**Style Guide**](./style-guide/) +### 6. Create an issue + The first step to any new contribution is an issue describing a problem with the current codebase or proposing a new feature. All the open issues are viewable on the GitHub repositories, for instance here is the [issues page for Sir Lancebot](https://github.com/python-discord/sir-lancebot/issues). If you have an idea that you want to implement, open a new issue (and check out our [**guide on writing an issue**](./issues/)). Otherwise you can browse the unassigned issues and ask to be assigned to one that you're interested in, either in the comments on the issue or in the`#dev-contrib`channel on Discord. -If you don't understand anything or need clarification, feel free to ask any staff member with the **@PyDis Core Developers** role in the server. We're always happy to help! + Don't move forward until your issue is approved by a Core Developer. Issues are not guaranteed to be approved so your work may be wasted. + {: .notification .is-warning } + +### 7. Open a pull request + After your issue has been approved and you've written your code and tested it, it's time to open a pull request. Pull requests are a feature in GitHub; you can think of them as asking the project maintainers to accept your changes. This gives other contributors a chance to review your code and make any needed changes before it's merged into the main branch of the project. + + Check out our [**Pull Request Guide**](./contributing/pull-requests/) for help with opening a pull request and going through the review process. + + Check out our [**Code Review Guide**](../code-reviews-primer/) to learn how to be a star reviewer. Reviewing PRs is a vital part of open source development, and we always need more reviewers! + +### That's it! +Thank you for contributing to our community projects. If you don't understand anything or need clarification, feel free to ask a question in the`dev-contrib`channel and keep an eye out for staff members with the **@PyDis Core Developers** role in the server. We're always happy to help! -- cgit v1.2.3 From e76e0a5f2d80a4f5ec31fe015155b0349f9f607b Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 20 Mar 2022 18:55:16 -0400 Subject: Fix PR page link --- pydis_site/apps/content/resources/guides/pydis-guides/contributing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index 759ed1dc..51991d9c 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -140,7 +140,7 @@ Our projects on Python Discord are open source and [available on GitHub](https:/ ### 7. Open a pull request After your issue has been approved and you've written your code and tested it, it's time to open a pull request. Pull requests are a feature in GitHub; you can think of them as asking the project maintainers to accept your changes. This gives other contributors a chance to review your code and make any needed changes before it's merged into the main branch of the project. - Check out our [**Pull Request Guide**](./contributing/pull-requests/) for help with opening a pull request and going through the review process. + Check out our [**Pull Request Guide**](./pull-requests/) for help with opening a pull request and going through the review process. Check out our [**Code Review Guide**](../code-reviews-primer/) to learn how to be a star reviewer. Reviewing PRs is a vital part of open source development, and we always need more reviewers! -- cgit v1.2.3 From 506ac4281dd150eefc4864a00838ac97aa71f14f Mon Sep 17 00:00:00 2001 From: Robin <74519799+Robin5605@users.noreply.github.com> Date: Mon, 21 Mar 2022 14:15:50 -0500 Subject: Add hyperlinks Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- .../content/resources/guides/python-guides/vps-services.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md index 02feb8e6..07e70e9a 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md +++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md @@ -5,21 +5,21 @@ description: On different VPS services If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS). This is a list of VPS services that are sufficient for running Discord bots. -* https://www.scaleway.com/ +* [https://www.scaleway.com](https://www.scaleway.com) * Based in Europe. -* https://www.digitalocean.com/ +* [https://www.digitalocean.com](https://www.digitalocean.com) * US-based. * Considered by many to be the gold standard. * Locations available across the world. -* https://www.ovh.co.uk/ +* [https://www.ovh.co.uk](https://www.ovh.co.uk) * France and Canadian locations available. -* https://www.time4vps.eu/ +* [https://www.time4vps.eu](https://www.time4vps.eu) * Seemingly based in Lithuania. -* https://www.linode.com/ +* [https://www.linode.com](https://www.linode.com) * Cheap VPS. -* https://www.vultr.com/ +* [https://www.vultr.com](https://www.vultr.com) * US-based, DigitalOcean-like. -* https://galaxygate.net/ +* [https://galaxygate.net](https://galaxygate.net) * A reliable, affordable, and trusted host. There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi (any model, except perhaps one of the particularly less powerful ones). -- cgit v1.2.3 From cf8ce93e34ab543fdc9e4df83aa4583128c2ddc0 Mon Sep 17 00:00:00 2001 From: Shom770 <82843611+Shom770@users.noreply.github.com> Date: Mon, 21 Mar 2022 18:42:04 -0400 Subject: add token safety pin --- .../guides/python-guides/keeping-tokens-safe.md | 21 +++++++++++++++++++++ .../resources/guides/python-guides/token-reset.png | Bin 0 -> 132625 bytes 2 files changed, 21 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md create mode 100644 pydis_site/apps/content/resources/guides/python-guides/token-reset.png (limited to 'pydis_site/apps') 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 new file mode 100644 index 00000000..8e283d70 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md @@ -0,0 +1,21 @@ +--- +title: Keeping Discord Bot Tokens Safe +description: How to keep your bot tokens safe and safety measures you can take. +--- +It's **very** important to keep a bot token safe, primarily because anyone who has the bot token can do whatever they want with the bot -- such as destroying servers your bot has been added to and getting your bot banned from the API. + +# How to Avoid Leaking your Token +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) + +# What should I do if my token does get leaked? + +If for whatever reason your token gets leaked, you should immediately follow these steps: +- Go to the list of [Discord Bot Applications](https://discord.com/developers/applications) you have and select the bot application that had the token leaked. +- Select the Bot (1) tab on the left-hand side, next to a small image of a puzzle piece. After doing so you should see a small section named TOKEN (under your bot USERNAME and next to his avatar image) +- Press the Regenerate (2) option to regen your bot token. +![token_reset.png](token-reset.png) + +Following these steps will create a new token for your bot, making it secure again and terminating any connections from the leaked token. + +# Summary +Make sure you keep your token secure by storing it safely, not sending it to anyone you don't trust, and regenerating your token if it does get leaked. \ No newline at end of file diff --git a/pydis_site/apps/content/resources/guides/python-guides/token-reset.png b/pydis_site/apps/content/resources/guides/python-guides/token-reset.png new file mode 100644 index 00000000..bd672b93 Binary files /dev/null and b/pydis_site/apps/content/resources/guides/python-guides/token-reset.png differ -- cgit v1.2.3 From 9ba4891e6e47f7f498eafa61e4a1d5e301f2426a Mon Sep 17 00:00:00 2001 From: Shom770 <82843611+Shom770@users.noreply.github.com> Date: Mon, 21 Mar 2022 18:59:26 -0400 Subject: fixing image not appearing --- .../guides/python-guides/keeping-tokens-safe.md | 3 ++- .../resources/guides/python-guides/token-reset.png | Bin 132625 -> 0 bytes 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 100644 pydis_site/apps/content/resources/guides/python-guides/token-reset.png (limited to 'pydis_site/apps') 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 8e283d70..e37039d1 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 @@ -13,7 +13,8 @@ If for whatever reason your token gets leaked, you should immediately follow the - Go to the list of [Discord Bot Applications](https://discord.com/developers/applications) you have and select the bot application that had the token leaked. - Select the Bot (1) tab on the left-hand side, next to a small image of a puzzle piece. After doing so you should see a small section named TOKEN (under your bot USERNAME and next to his avatar image) - Press the Regenerate (2) option to regen your bot token. -![token_reset.png](token-reset.png) + +![Steps to Take to Reset your Discord Bot](https://cdn.discordapp.com/attachments/343944376055103488/845290595793764392/regen_token.png) Following these steps will create a new token for your bot, making it secure again and terminating any connections from the leaked token. diff --git a/pydis_site/apps/content/resources/guides/python-guides/token-reset.png b/pydis_site/apps/content/resources/guides/python-guides/token-reset.png deleted file mode 100644 index bd672b93..00000000 Binary files a/pydis_site/apps/content/resources/guides/python-guides/token-reset.png and /dev/null differ -- cgit v1.2.3 From 45d13700c5421d056e89797c77f7730419300242 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Tue, 22 Mar 2022 15:02:03 +0400 Subject: Add custom help command --- .../resources/guides/python-guides/discordpy_help_command.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md b/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md index 58d75918..39a45063 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md @@ -3,9 +3,6 @@ title: Custom Help Command description: "Overwrite discord.py's help command to implement custom functionality" --- -# Custom Help Command - - First, a [basic walkthrough](https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96) by Stella#2000 on subclassing the HelpCommand will provide some foundational knowledge required before attempting a more customizable help command. --- @@ -23,7 +20,7 @@ class MyHelpCommand(commands.HelpCommand): bot.help_command = MyHelpCommand() ``` --- -You can handle when there is no command and make a fancy embed; a page that describes the bot and shows a list of commands is usually ideal here, however if a command is passed in, you can get display detailed information of the command with reference from the following documentations below: +You can handle when a user does not pass a command name when invoking the help command and make a fancy and customized embed; here a page that describes the bot and shows a list of commands is generally used, however if a command is passed in, you can display detailed information of the command. Below are references from the documentation below that can be utilised: * [Get the command object](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot.get_command) @@ -35,4 +32,4 @@ You can handle when there is no command and make a fancy embed; a page that des * [Get the command usage](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Command.usage) -* Get the command cooldown - `command_object._buckets._cooldown.per.` +* Get the command cooldown - `command_object._buckets._cooldown.per` -- cgit v1.2.3 From 00a17a53c542b6de263691219c38438393d9f806 Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Tue, 22 Mar 2022 12:33:13 -0500 Subject: Update VPS services content --- .../resources/guides/python-guides/vps-services.md | 53 +++++++++++++--------- 1 file changed, 32 insertions(+), 21 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md index 07e70e9a..4dfca732 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md +++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md @@ -1,25 +1,36 @@ --- -title: VPS Services -description: On different VPS services + title: VPS Services + description: On different VPS services --- -If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS). -This is a list of VPS services that are sufficient for running Discord bots. -* [https://www.scaleway.com](https://www.scaleway.com) - * Based in Europe. -* [https://www.digitalocean.com](https://www.digitalocean.com) - * US-based. - * Considered by many to be the gold standard. - * Locations available across the world. -* [https://www.ovh.co.uk](https://www.ovh.co.uk) - * France and Canadian locations available. -* [https://www.time4vps.eu](https://www.time4vps.eu) - * Seemingly based in Lithuania. -* [https://www.linode.com](https://www.linode.com) - * Cheap VPS. -* [https://www.vultr.com](https://www.vultr.com) - * US-based, DigitalOcean-like. -* [https://galaxygate.net](https://galaxygate.net) - * A reliable, affordable, and trusted host. +If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS).This is a list of VPS services that are sufficient for running Discord bots. + +* Europe + * [netcup](https://www.netcup.eu/) + * Germany & Austria data centres. + * Great affiliate program. + * [Yandex Cloud](https://cloud.yandex.ru/) + * Vladimir, Ryazan, and Moscow region data centres. + * [Scaleway](https://www.scaleway.com/) + * France data centre. + * [Time 4 VPS](https://www.time4vps.eu/) + * Lithuania data centre. +* US + * [GalaxyGate](https://galaxygate.net/) + * New York data centre. + * Great affiliate program. +* Global + * [Linode](https://www.linode.com/) + * Global data centre options. + * [Digital Ocean](https://www.digitalocean.com/) + * Global data centre options. + * [OVHcloud](https://www.ovhcloud.com/) + * Global data centre options. + * [Vultr](https://www.vultr.com/) + * Global data centre options. + +--- +Free hosts +There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. +Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi. -There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi (any model, except perhaps one of the particularly less powerful ones). -- cgit v1.2.3 From 3ff628ad44b80f1c5483832f72ee8b63bcbc4fdb Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Tue, 22 Mar 2022 18:55:02 +0100 Subject: Add UniqueConstraint to the Filter model - The UniqueConstraint includes every field, except for id and description. --- .../migrations/0080_unique_constraint_filters.py | 36 ++++++++++++++++++++++ pydis_site/apps/api/models/bot/filters.py | 28 ++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 pydis_site/apps/api/migrations/0080_unique_constraint_filters.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0080_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0080_unique_constraint_filters.py new file mode 100644 index 00000000..0b3b4162 --- /dev/null +++ b/pydis_site/apps/api/migrations/0080_unique_constraint_filters.py @@ -0,0 +1,36 @@ +# Generated by Django 3.1.14 on 2022-03-22 16:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0079_new_filter_schema'), + ] + + operations = [ + migrations.AddConstraint( + model_name='filter', + constraint=models.UniqueConstraint(fields=( + 'dm_content', + 'dm_embed', + 'infraction_type', + 'infraction_reason', + 'infraction_duration', + 'content', + 'additional_field', + 'filter_list', + 'guild_pings', + 'filter_dm', + 'dm_pings', + 'delete_messages', + 'bypass_roles', + 'enabled', + 'send_alert', + 'enabled_channels', + 'disabled_channels', + 'disabled_categories' + ), name='unique_filters'), + ), + ] diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index f8bbfd14..708ceadc 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -113,7 +113,7 @@ class FilterList(FilterSettingsMixin): return f"Filter {FilterListType(self.list_type).label}list {self.name!r}" -class Filter(FilterSettingsMixin): +class FilterBase(FilterSettingsMixin): """One specific trigger of a list.""" content = models.CharField(max_length=100, help_text="The definition of this filter.") @@ -173,3 +173,29 @@ class Filter(FilterSettingsMixin): def __str__(self) -> str: return f"Filter {self.content!r}" + + class Meta: + """Metaclass for FilterBase to make it abstract model.""" + + abstract = True + + +class Filter(FilterBase): + """ + The main Filter models based on `FilterBase`. + + The purpose to have this model is to have access to the Fields of the Filter model + and set the unique constraint based on those fields. + """ + + class Meta: + """Metaclass Filter to set the unique constraint.""" + + constraints = ( + UniqueConstraint( + fields=tuple( + [field.name for field in FilterBase._meta.fields + if field.name != "id" and field.name != "description"] + ), + name="unique_filters"), + ) -- cgit v1.2.3 From aedf67e6f141288f1f8b00bbd0e46cae1eb16a6c Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Tue, 22 Mar 2022 14:22:27 -0500 Subject: Fix EOF --- pydis_site/apps/content/resources/guides/python-guides/vps-services.md | 1 - 1 file changed, 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md index 4dfca732..95951134 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md +++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md @@ -33,4 +33,3 @@ If you need to run your bot 24/7 (with no downtime), you should consider using a Free hosts There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi. - -- cgit v1.2.3 From 682613bcf56ea6aefac8b15b56064a16732894a4 Mon Sep 17 00:00:00 2001 From: Robin <74519799+Robin5605@users.noreply.github.com> Date: Tue, 22 Mar 2022 23:51:36 -0500 Subject: Remove starting whitespaces Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/vps-services.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md index 95951134..90306057 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md +++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md @@ -1,6 +1,6 @@ --- - title: VPS Services - description: On different VPS services +title: VPS Services +description: On different VPS services --- If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS).This is a list of VPS services that are sufficient for running Discord bots. -- cgit v1.2.3 From 50d6efdb1c452f1265cec83a76e8ab631d37ba22 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Wed, 23 Mar 2022 10:31:12 +0400 Subject: Add documentation on subclassing bot --- .../guides/python-guides/subclassing_bot.md | 51 ++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md new file mode 100644 index 00000000..f22f98f5 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -0,0 +1,51 @@ +--- +title: Subclassing Bot +description: "Subclassing the Bot to add more functionability and customisability." +--- + +## Basic Subclassing +First, a [basic article](https://www.codesdope.com/course/python-subclass-of-a-class/) on subclassing will provide some fundamental knowledge, which is highly suggested before moving on to this topic, as subclassing [Bot](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot) can ultimately be a complicated task. + +## The benefits of subclassing bot +Subclassing Bot can be very beneficial as it provides you with more control and customisability of how your bot functions, also allowing you to add extra features, such as custom bot attributes or methods. For example, the default [Context](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) can be overriden to add more functionality. + +There are two ways to subclass `commands.Bot`, as shown below: +```py +class CustomBot(commands.Bot): + def __init__(self): + super().__init__( + command_prefix = #your prefix here as a string + intents = #your intents here + #other kwargs can be put here + ) + #custom bot attributes can be set here, for example: + self.db = self.loop.run_until_complete(aiosqlite.connect("Your database file name")) + self.launch_time = datetime.datetime.utcnow() + self.example_integer = 5 + +bot = CustomBot() +``` +Or +```py +class CustomBot(commands.Bot): + def __init__(self, *args, **kwargs): #the key-word arguments are not specified here, unlike the example above + + super().__init__(*args, **kwargs) + #custom bot attributes can be set here, for example: + self.example_string = 'This is an example!' + + #You can add a custom bot method, anyhting can be done in this function. This is an example: + def hello(self): + return 'Hello World' + +#Here you set the *args and **kwargs +bot = CustomBot(command_prefix="!", intents=discord.Intents.default()) + +@bot.command() +async def example(ctx): + print(bot.hello()) + # In this case, this will print Hello World! +``` +With either of the above examples, you are not required to change any of the existing or future code, it is identical to code done without subclassing bot. + +To access the custom bot attributes set in the subclass, in the main bot file (in the context of the above example), `bot.variable_name` would be used, and as for cogs, it would be `self.bot.variable_name`. For the custom methods set, in the main file it would be `bot.custom_method()` in the main file and `self.bot.custom_method()` in a cog file. -- cgit v1.2.3 From 80d87b861ae585222bc09cc8bf5b305dea8b49a1 Mon Sep 17 00:00:00 2001 From: Robin <74519799+Robin5605@users.noreply.github.com> Date: Wed, 23 Mar 2022 12:40:55 -0500 Subject: Add single space after quotes --- .../resources/guides/python-guides/subclassing_bot.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index f22f98f5..5f1e7ccf 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -14,11 +14,11 @@ There are two ways to subclass `commands.Bot`, as shown below: class CustomBot(commands.Bot): def __init__(self): super().__init__( - command_prefix = #your prefix here as a string - intents = #your intents here - #other kwargs can be put here + command_prefix = # your prefix here as a string + intents = # your intents here + # other kwargs can be put here ) - #custom bot attributes can be set here, for example: + # custom bot attributes can be set here, for example: self.db = self.loop.run_until_complete(aiosqlite.connect("Your database file name")) self.launch_time = datetime.datetime.utcnow() self.example_integer = 5 @@ -28,17 +28,17 @@ bot = CustomBot() Or ```py class CustomBot(commands.Bot): - def __init__(self, *args, **kwargs): #the key-word arguments are not specified here, unlike the example above + def __init__(self, *args, **kwargs): # the key-word arguments are not specified here, unlike the example above super().__init__(*args, **kwargs) - #custom bot attributes can be set here, for example: + # custom bot attributes can be set here, for example: self.example_string = 'This is an example!' - #You can add a custom bot method, anyhting can be done in this function. This is an example: + # You can add a custom bot method, anything can be done in this function. This is an example: def hello(self): return 'Hello World' -#Here you set the *args and **kwargs +# Here you set the *args and **kwargs bot = CustomBot(command_prefix="!", intents=discord.Intents.default()) @bot.command() -- cgit v1.2.3 From 806ce3fdf433d05b9d480a3d7ca2332dac951194 Mon Sep 17 00:00:00 2001 From: Krypton Date: Wed, 23 Mar 2022 22:35:32 +0100 Subject: Update discord-messages-with-colors.md --- .../python-guides/discord-messages-with-colors.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) (limited to 'pydis_site/apps') 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 30f40948..c8e50d71 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,13 +3,13 @@ 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 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. +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. -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: -``` +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: +```ansi \u001b[{format};{color}m ``` -*The `\u001b` is the unicode for ESCAPE/ESC, see .* ***If you want to use it yourself without bots, then you need to copy paste the character from the website.*** +*`\u001b` is the unicode escape for ESCAPE/ESC, meant to be used in the source of your bot (see ).* ***If you wish to send colored text without using your bot you need to copy the character from the website.*** After you've written this, you can type and text you wish, and if you want to reset the color back to normal, then you need to use `\u001b[0m` as prefix. @@ -34,13 +34,13 @@ Here is the list of values you can use to replace `{color}`: *The following values will change the **text background** color.* -* 40: Some very dark blue +* 40: Firefly dark blue * 41: Orange -* 42: Gray -* 43: Light gray -* 44: Even lighter gray +* 42: Marble blue +* 43: Greyish turquoise +* 44: Gray * 45: Indigo -* 46: Again some gray +* 46: Light gray * 47: White Let's take an example, I want a bold green colored text with the very dark blue background. @@ -54,7 +54,8 @@ or
      \u001b[1;40;32mThat's some cool formatted text right?
      \`\`\` -Result:
      +Result: + ![Background and text color](https://media.discordapp.net/attachments/739937507768270939/930460020603224084/Background-Text-Color.png) The way the colors look like on Discord is shown in the image below: -- cgit v1.2.3 From caaecf572615e9177fe53c6f81212d7a7ca6d2f6 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Thu, 24 Mar 2022 13:26:21 +0400 Subject: Update subclassing_bot.md --- .../guides/python-guides/subclassing_bot.md | 27 ++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 5f1e7ccf..0f9dbf78 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -14,31 +14,40 @@ There are two ways to subclass `commands.Bot`, as shown below: class CustomBot(commands.Bot): def __init__(self): super().__init__( - command_prefix = # your prefix here as a string - intents = # your intents here - # other kwargs can be put here + command_prefix = #your prefix here as a string + intents = #your intents here + #other kwargs can be put here ) - # custom bot attributes can be set here, for example: - self.db = self.loop.run_until_complete(aiosqlite.connect("Your database file name")) + #custom bot attributes can be set here, for example: self.launch_time = datetime.datetime.utcnow() self.example_integer = 5 + + async def start(self, *args, **kwargs): + # here you are overriding the default start method. You can do some code here for example establish a database connection + #as shown in this example below + self.db = await aiosqlite.connect('database file name.db') + await super().start(*args, **kwargs) + + bot = CustomBot() +token = YOUR_TOKEN_HERE +bot.run(token) ``` Or ```py class CustomBot(commands.Bot): - def __init__(self, *args, **kwargs): # the key-word arguments are not specified here, unlike the example above + def __init__(self, *args, **kwargs): #the key-word arguments are not specified here, unlike the example above super().__init__(*args, **kwargs) - # custom bot attributes can be set here, for example: + #custom bot attributes can be set here, for example: self.example_string = 'This is an example!' - # You can add a custom bot method, anything can be done in this function. This is an example: + #You can add a custom bot method, anyhting can be done in this function. This is an example: def hello(self): return 'Hello World' -# Here you set the *args and **kwargs +#Here you set the *args and **kwargs bot = CustomBot(command_prefix="!", intents=discord.Intents.default()) @bot.command() -- cgit v1.2.3 From 134c304672a925600663e85ee2d76bc84c8db20f Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Thu, 24 Mar 2022 14:22:12 +0400 Subject: Delete discordpy_help_command.md --- .../guides/python-guides/discordpy_help_command.md | 35 ---------------------- 1 file changed, 35 deletions(-) delete mode 100644 pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md b/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md deleted file mode 100644 index 39a45063..00000000 --- a/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: Custom Help Command -description: "Overwrite discord.py's help command to implement custom functionality" ---- - - First, a [basic walkthrough](https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96) by Stella#2000 on subclassing the HelpCommand will provide some foundational knowledge required before attempting a more customizable help command. - ---- - -## Custom Subclass of Help Command -If this does not fit your needs and you require a more customizable help command, you can subclass HelpCommand and add individual command details. Below is a basic demonstration: - -```python -class MyHelpCommand(commands.HelpCommand): - async def command_callback(self, ctx, *,command=None): - if command: - await ctx.send(f"This is the help page for the command {command} ") - else: - await ctx.send("This is the front page for the bots help command") -bot.help_command = MyHelpCommand() -``` ---- -You can handle when a user does not pass a command name when invoking the help command and make a fancy and customized embed; here a page that describes the bot and shows a list of commands is generally used, however if a command is passed in, you can display detailed information of the command. Below are references from the documentation below that can be utilised: - -* [Get the command object](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot.get_command) - -* [Get the command name](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Command.name) - -* [Get the command aliases](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Command.aliases) - -* [Get the command brief](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Command.brief) - -* [Get the command usage](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Command.usage) - -* Get the command cooldown - `command_object._buckets._cooldown.per` -- cgit v1.2.3 From 1017676bea05d423d86a29c2ede769a6288a3d4a Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sat, 26 Mar 2022 22:33:03 -0400 Subject: Fix commit-messages link --- .../guides/pydis-guides/contributing/contributing-guidelines.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md index f13b05be..1917ee43 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md @@ -12,7 +12,7 @@ We have simple but strict style rules that are enforced through linting. Not all of the style rules are enforced by linting, so make sure to read the [style guide](../style-guide/) as well. 2. **Make great commits.** Great commits should be atomic, with a commit message explaining what and why. -Check out [Writing Good Commit Messages](/commit-messages) for details. +Check out [Writing Good Commit Messages](./commit-messages) for details. 3. **Do not open a pull request if you aren't assigned to the issue.** If someone is already working on it, consider offering to collaborate with that person. 4. **Use assets licensed for public use.** -- cgit v1.2.3 From 15a32db18b72291913cf80b9fc85ac578b824f34 Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 27 Mar 2022 00:06:14 -0400 Subject: Make small wording improvements --- .../apps/content/resources/guides/pydis-guides/contributing.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index 51991d9c..4b53d978 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -92,7 +92,7 @@ Our projects on Python Discord are open source and [available on GitHub](https:/ # How do I start contributing? - Unsure of what contributing to open source projects involves? Have questions about how to use GitHub? Just need to know about our contribution etiquette? Completing these steps will have you ready to make your first contribution. + Unsure of what contributing to open source projects involves? Have questions about how to use GitHub? Just need to know about our contribution etiquette? Completing these steps will have you ready to make your first contribution no matter your starting point. Feel free to skip any steps you're already familiar with, but please make sure not to miss the [Contributing Guidelines](#5-read-our-contributing-guidelines). @@ -107,7 +107,6 @@ Our projects on Python Discord are open source and [available on GitHub](https:/ GitHub is a website based on the Git version control system that stores project files in the cloud. The people working on the project can use GitHub as a central place for sending their changes, getting their teammates' changes, and communicating with each other. Forking the repository that you want to work on will create a copy under your own GitHub account. You'll make your changes to this copy, then later we can bring them back to the PyDis repository. Check out our [**guide on forking a GitHub repo**](./forking-repository/). - ### 3. Clone the repo Now that you have your own fork you need to be able to make changes to the code. You can clone the repo to your local machine, commit changes to it there, then push those changes to GitHub. @@ -145,4 +144,4 @@ Our projects on Python Discord are open source and [available on GitHub](https:/ Check out our [**Code Review Guide**](../code-reviews-primer/) to learn how to be a star reviewer. Reviewing PRs is a vital part of open source development, and we always need more reviewers! ### That's it! -Thank you for contributing to our community projects. If you don't understand anything or need clarification, feel free to ask a question in the`dev-contrib`channel and keep an eye out for staff members with the **@PyDis Core Developers** role in the server. We're always happy to help! +Thank you for contributing to our community projects. If there's anything you don't understand or you just want to discuss with other contributors, come visit the`dev-contrib`channel to ask questions. Keep an eye out for staff members with the **@PyDis Core Developers** role in the server; we're always happy to help! -- cgit v1.2.3 From d167c7dc53031fd936fa0ee438d6a240bf45fb76 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Mon, 28 Mar 2022 09:16:57 +0400 Subject: Update pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md Co-authored-by: Shom770 <82843611+Shom770@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 0f9dbf78..f6d554eb 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -1,6 +1,6 @@ --- title: Subclassing Bot -description: "Subclassing the Bot to add more functionability and customisability." +description: "Subclassing the Bot to add more functionality and customisability." --- ## Basic Subclassing -- cgit v1.2.3 From 087a8f84e992ce2cbde910b4381ed1d052b88c40 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Mon, 28 Mar 2022 09:17:06 +0400 Subject: Update pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md Co-authored-by: Shom770 <82843611+Shom770@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index f6d554eb..6b798400 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -14,8 +14,8 @@ There are two ways to subclass `commands.Bot`, as shown below: class CustomBot(commands.Bot): def __init__(self): super().__init__( - command_prefix = #your prefix here as a string - intents = #your intents here + command_prefix=#your prefix here as a string + intents=#your intents here #other kwargs can be put here ) #custom bot attributes can be set here, for example: -- cgit v1.2.3 From 93413138d1342a110c50a2f77f74253f5c06fdd4 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Mon, 28 Mar 2022 09:17:15 +0400 Subject: Update pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md Co-authored-by: Shom770 <82843611+Shom770@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 6b798400..bd8cbdc8 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -18,7 +18,7 @@ class CustomBot(commands.Bot): intents=#your intents here #other kwargs can be put here ) - #custom bot attributes can be set here, for example: + # custom bot attributes can be set here, for example: self.launch_time = datetime.datetime.utcnow() self.example_integer = 5 -- cgit v1.2.3 From dd55548bf67a670e74b8a3a0424241eecd0f2d48 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Mon, 28 Mar 2022 09:17:22 +0400 Subject: Update pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md Co-authored-by: Shom770 <82843611+Shom770@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index bd8cbdc8..be9bb6e8 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -25,7 +25,7 @@ class CustomBot(commands.Bot): async def start(self, *args, **kwargs): # here you are overriding the default start method. You can do some code here for example establish a database connection - #as shown in this example below + # as shown in this example below self.db = await aiosqlite.connect('database file name.db') await super().start(*args, **kwargs) -- cgit v1.2.3 From 9d0391b330adfbc994e1b1a344dbc1e4f1a2c76d Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Mon, 28 Mar 2022 09:17:30 +0400 Subject: Update pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md Co-authored-by: Shom770 <82843611+Shom770@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index be9bb6e8..37f7a9d2 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -37,7 +37,7 @@ bot.run(token) Or ```py class CustomBot(commands.Bot): - def __init__(self, *args, **kwargs): #the key-word arguments are not specified here, unlike the example above + def __init__(self, *args, **kwargs): # the key-word arguments are not specified here, unlike the example above super().__init__(*args, **kwargs) #custom bot attributes can be set here, for example: -- cgit v1.2.3 From 671660b8f0a930b2c80801df0e2c62ef8c86da83 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Mon, 28 Mar 2022 09:17:37 +0400 Subject: Update pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md Co-authored-by: Shom770 <82843611+Shom770@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 37f7a9d2..49ad59f0 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -43,7 +43,7 @@ class CustomBot(commands.Bot): #custom bot attributes can be set here, for example: self.example_string = 'This is an example!' - #You can add a custom bot method, anyhting can be done in this function. This is an example: + # You can add a custom bot method, anyhting can be done in this function. This is an example: def hello(self): return 'Hello World' -- cgit v1.2.3 From f228039ff07aeac48729074c5eb1efeb16b043b4 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Mon, 28 Mar 2022 09:18:08 +0400 Subject: Update pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md Co-authored-by: Shom770 <82843611+Shom770@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 49ad59f0..1efbb571 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -40,7 +40,7 @@ class CustomBot(commands.Bot): def __init__(self, *args, **kwargs): # the key-word arguments are not specified here, unlike the example above super().__init__(*args, **kwargs) - #custom bot attributes can be set here, for example: + # custom bot attributes can be set here, for example: self.example_string = 'This is an example!' # You can add a custom bot method, anyhting can be done in this function. This is an example: -- cgit v1.2.3 From 7f78aa493590e9533905b942c6b18653fc0108ea Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Mon, 28 Mar 2022 09:18:53 +0400 Subject: Update subclassing_bot.md --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 1efbb571..060dfacb 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -47,7 +47,7 @@ class CustomBot(commands.Bot): def hello(self): return 'Hello World' -#Here you set the *args and **kwargs +# Here you set the *args and **kwargs bot = CustomBot(command_prefix="!", intents=discord.Intents.default()) @bot.command() -- cgit v1.2.3 From b08d93869e7e3a2f032b02e711d95fcde6216aa2 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Mon, 28 Mar 2022 09:20:05 +0400 Subject: Update subclassing_bot.md --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 060dfacb..381f25f1 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -14,9 +14,9 @@ There are two ways to subclass `commands.Bot`, as shown below: class CustomBot(commands.Bot): def __init__(self): super().__init__( - command_prefix=#your prefix here as a string - intents=#your intents here - #other kwargs can be put here + command_prefix= # Your prefix here as a string + intents= # Your intents here + # Other kwargs can be put here ) # custom bot attributes can be set here, for example: self.launch_time = datetime.datetime.utcnow() -- cgit v1.2.3 From 08d5e01be09e482f92a96cf00bc007e60dfa2c98 Mon Sep 17 00:00:00 2001 From: Robin <74519799+Robin5605@users.noreply.github.com> Date: Thu, 31 Mar 2022 10:46:33 -0500 Subject: Make "free hosts" a header Co-authored-by: Shom770 <82843611+Shom770@users.noreply.github.com> --- pydis_site/apps/content/resources/guides/python-guides/vps-services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md index 90306057..b8a6f8fd 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md +++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md @@ -30,6 +30,6 @@ If you need to run your bot 24/7 (with no downtime), you should consider using a * Global data centre options. --- -Free hosts +# Free hosts There are no reliable free options for VPS hosting. If you would rather not pay for a hosting service, you can consider self-hosting. Any modern hardware should be sufficient for running a bot. An old computer with a few GB of ram could be suitable, or a Raspberry Pi. -- cgit v1.2.3 From 0cc092741ea35b5810d57d4d768dcb0e021603bd Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Thu, 31 Mar 2022 10:50:38 -0500 Subject: Remove extraneous bullet points in "global" --- .../apps/content/resources/guides/python-guides/vps-services.md | 4 ---- 1 file changed, 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md index b8a6f8fd..bd4af0a5 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md +++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md @@ -21,13 +21,9 @@ If you need to run your bot 24/7 (with no downtime), you should consider using a * Great affiliate program. * Global * [Linode](https://www.linode.com/) - * Global data centre options. * [Digital Ocean](https://www.digitalocean.com/) - * Global data centre options. * [OVHcloud](https://www.ovhcloud.com/) - * Global data centre options. * [Vultr](https://www.vultr.com/) - * Global data centre options. --- # Free hosts -- cgit v1.2.3 From 1e916a0515940caa95f34fc0bc04a040b36c214d Mon Sep 17 00:00:00 2001 From: Robin <74519799+Robin5605@users.noreply.github.com> Date: Thu, 31 Mar 2022 11:52:57 -0500 Subject: Add space Co-authored-by: Hassan Abouelela --- pydis_site/apps/content/resources/guides/python-guides/vps-services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md index bd4af0a5..0acd3e55 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/vps-services.md +++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md @@ -3,7 +3,7 @@ title: VPS Services description: On different VPS services --- -If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS).This is a list of VPS services that are sufficient for running Discord bots. +If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS). This is a list of VPS services that are sufficient for running Discord bots. * Europe * [netcup](https://www.netcup.eu/) -- cgit v1.2.3 From 1005229e825a5f2449168bcae2b85667c5799265 Mon Sep 17 00:00:00 2001 From: Krypton Date: Fri, 1 Apr 2022 18:15:19 +0200 Subject: Added Discord embed limits guide (#690) Co-authored-by: Krypton Co-authored-by: Krypton Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- .../guides/python-guides/discord-embed-limits.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/discord-embed-limits.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-embed-limits.md b/pydis_site/apps/content/resources/guides/python-guides/discord-embed-limits.md new file mode 100644 index 00000000..ca97462b --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-embed-limits.md @@ -0,0 +1,21 @@ +--- +title: Discord Embed Limits +description: A guide that shows the limits of embeds in Discord and how to avoid them. +--- + +If you plan on using embed responses for your bot you should know the limits of the embeds on Discord or you will get `Invalid Form Body` errors: + +- Embed **title** is limited to **256 characters** +- Embed **description** is limited to **4096 characters** +- An embed can contain a maximum of **25 fields** +- A **field name/title** is limited to **256 character** and the **value of the field** is limited to **1024 characters** +- Embed **footer** is limited to **2048 characters** +- Embed **author name** is limited to **256 characters** +- The **total of characters** allowed in an embed is **6000** + +Now if you need to get over this limit (for example for a help command), you would need to use pagination. +There are several ways to do that: + +- A library called **[disputils](https://pypi.org/project/disputils)** +- An experimental library made by the discord.py developer called **[discord-ext-menus](https://github.com/Rapptz/discord-ext-menus)** +- Make your own setup using **[wait_for()](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.Bot.wait_for)** and wait for a reaction to be added -- cgit v1.2.3 From 306a906c850324a4a31a62d82d5348a18f3f2e5e Mon Sep 17 00:00:00 2001 From: Shom770 <82843611+Shom770@users.noreply.github.com> Date: Sat, 2 Apr 2022 12:16:13 -0400 Subject: Update pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/keeping-tokens-safe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') 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 e37039d1..6b5dae34 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 @@ -12,7 +12,7 @@ To help prevent leaking your token, you should ensure that you don't upload it t If for whatever reason your token gets leaked, you should immediately follow these steps: - Go to the list of [Discord Bot Applications](https://discord.com/developers/applications) you have and select the bot application that had the token leaked. - Select the Bot (1) tab on the left-hand side, next to a small image of a puzzle piece. After doing so you should see a small section named TOKEN (under your bot USERNAME and next to his avatar image) -- Press the Regenerate (2) option to regen your bot token. +- Press the Regenerate option to regen your bot token. ![Steps to Take to Reset your Discord Bot](https://cdn.discordapp.com/attachments/343944376055103488/845290595793764392/regen_token.png) -- cgit v1.2.3 From 788c8fc1b3fc54fa35dc8282b8a1267314e85a77 Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Thu, 7 Apr 2022 22:44:09 -0400 Subject: Replace difficulty badges with more description --- .../resources/guides/pydis-guides/contributing.md | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index 4b53d978..e4b3e621 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -19,11 +19,7 @@ Our projects on Python Discord are open source and [available on GitHub](https:/
      - Our community-driven Discord bot. -
      -
      - Difficulty - Beginner + Sir Lancebot has a collection of self-contained, for-fun features. If you're new to Discord bots or contributing, this is a great place to start!
      - The community and moderation Discord bot. -
      -
      - Difficulty - Intermediate + Called @Python on the server, this bot handles moderation tools, help channels, and other critical features for our community.
      - The website, subdomains and API. -
      -
      - Difficulty - Advanced + This website itself! This project is built with Django and includes our API, which is used by various services such as @Python.
      # How do I start contributing? - Unsure of what contributing to open source projects involves? Have questions about how to use GitHub? Just need to know about our contribution etiquette? Completing these steps will have you ready to make your first contribution no matter your starting point. +Unsure of what contributing to open source projects involves? Have questions about how to use GitHub? Just need to know about our contribution etiquette? Completing these steps will have you ready to make your first contribution no matter your starting point. - Feel free to skip any steps you're already familiar with, but please make sure not to miss the [Contributing Guidelines](#5-read-our-contributing-guidelines). +Feel free to skip any steps you're already familiar with, but please make sure not to miss the [Contributing Guidelines](#5-read-our-contributing-guidelines). - If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides. +If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides. - **Note:** We use Git to keep track of changes to the files in our projects. Git allows you to make changes to your local code and then distribute those changes to the other people working on the project. You'll use Git in a couple steps of the contributing process. You can refer to this [**guide on using Git**](./working-with-git/). - {: .notification } +**Note:** We use Git to keep track of changes to the files in our projects. Git allows you to make changes to your local code and then distribute those changes to the other people working on the project. You'll use Git in a couple steps of the contributing process. You can refer to this [**guide on using Git**](./working-with-git/). +{: .notification } ### 1. Fork and clone the repo GitHub is a website based on Git that stores project files in the cloud. We use GitHub as a central place for sending changes, reviewing others' changes, and communicating with each other. You'll need to create a copy of the repository under your own GitHub account, aka "fork" it. You'll make your changes to this copy, which can then later be merged into the Python Discord repository. -- cgit v1.2.3 From d4150fdf04b3712655239a4fd77ee066f0920eda Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 24 Apr 2022 15:13:28 -0400 Subject: Move link to issue guide to match others --- pydis_site/apps/content/resources/guides/pydis-guides/contributing.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index fa829c9f..e19e2cea 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -117,7 +117,9 @@ As mentioned in the Contributing Guidelines, we have a simple style guide for ou [**Style Guide**](./style-guide/) ### 4. Create an issue -The first step to any new contribution is an issue describing a problem with the current codebase or proposing a new feature. All the open issues are viewable on the GitHub repositories, for instance here is the [issues page for Sir Lancebot](https://github.com/python-discord/sir-lancebot/issues). If you have an idea that you want to implement, open a new issue (and check out our [**guide on writing an issue**](./issues/)). Otherwise you can browse the unassigned issues and ask to be assigned to one that you're interested in, either in the comments on the issue or in the [`#dev-contrib`](https://discord.gg/2h3qBv8Xaa) channel on Discord. +The first step to any new contribution is an issue describing a problem with the current codebase or proposing a new feature. All the open issues are viewable on the GitHub repositories, for instance here is the [issues page for Sir Lancebot](https://github.com/python-discord/sir-lancebot/issues). If you have something that you want to implement open a new issue to present your idea. Otherwise you can browse the unassigned issues and ask to be assigned to one that you're interested in, either in the comments on the issue or in the [`#dev-contrib`](https://discord.gg/2h3qBv8Xaa) channel on Discord. + +[**How to write a good issue**](./issues/) Don't move forward until your issue is approved by a Core Developer. Issues are not guaranteed to be approved so your work may be wasted. {: .notification .is-warning } -- cgit v1.2.3 From 371a5b7650b4fbea1ff30e0afd63563882f33b3f Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 24 Apr 2022 15:14:08 -0400 Subject: Revert "Make it clearer what forking is" This reverts commit 32cf915246fbec5d3dc792d717b50af635108ca3. --- pydis_site/apps/content/resources/guides/pydis-guides/contributing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index e19e2cea..db466029 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -90,7 +90,7 @@ If you are here looking for the answer to a specific question, check out the sub {: .notification } ### 1. Fork and clone the repo -GitHub is a website based on Git that stores project files in the cloud. We use GitHub as a central place for sending changes, reviewing others' changes, and communicating with each other. You'll need to create a copy of the repository under your own GitHub account, aka "fork" it. You'll make your changes to this copy, which can then later be merged into the Python Discord repository. +GitHub is a website based on Git that stores project files in the cloud. We use GitHub as a central place for sending changes, reviewing others' changes, and communicating with each other. You'll need to fork the repository to create a copy under your own GitHub account. You'll make your changes to this copy, which can then later be merged into the Python Discord repository. Check out our [**guide on forking a GitHub repo**](./forking-repository/). -- cgit v1.2.3 From d75612d9431c330481a16d61d43aaefea733eb8f Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 24 Apr 2022 15:15:59 -0400 Subject: Make it more clear what forking is --- pydis_site/apps/content/resources/guides/pydis-guides/contributing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index db466029..bed2ffa1 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -90,7 +90,7 @@ If you are here looking for the answer to a specific question, check out the sub {: .notification } ### 1. Fork and clone the repo -GitHub is a website based on Git that stores project files in the cloud. We use GitHub as a central place for sending changes, reviewing others' changes, and communicating with each other. You'll need to fork the repository to create a copy under your own GitHub account. You'll make your changes to this copy, which can then later be merged into the Python Discord repository. +GitHub is a website based on Git that stores project files in the cloud. We use GitHub as a central place for sending changes, reviewing others' changes, and communicating with each other. You'll need to create a copy under your own GitHub account, a.k.a. "fork" it. You'll make your changes to this copy, which can then later be merged into the Python Discord repository. Check out our [**guide on forking a GitHub repo**](./forking-repository/). -- cgit v1.2.3 From fa1dd4058e8606f165df5ff7902d9215eeec4679 Mon Sep 17 00:00:00 2001 From: Krypton Date: Thu, 28 Apr 2022 19:00:06 +0200 Subject: Using static images --- .../python-guides/discord-messages-with-colors.md | 8 ++++---- .../content/discord_colored_messages/ansi-colors.png | Bin 0 -> 33579 bytes .../images/content/discord_colored_messages/result.png | Bin 0 -> 13740 bytes 3 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 pydis_site/static/images/content/discord_colored_messages/ansi-colors.png create mode 100644 pydis_site/static/images/content/discord_colored_messages/result.png (limited to 'pydis_site/apps') 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 c8e50d71..6845ce76 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 @@ -48,17 +48,17 @@ I simply use `\u001b[0;40m` (background color) and `\u001b[1;32m` (text color) a 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. Raw message:
      -\`\`\`ansi
      +\`\`\`ansi \u001b[0;40m\u001b[1;32mThat's some cool formatted text right?
      -or
      +or \u001b[1;40;32mThat's some cool formatted text right?
      \`\`\` Result: -![Background and text color](https://media.discordapp.net/attachments/739937507768270939/930460020603224084/Background-Text-Color.png) +![Background and text color result](/static/images/content/discord_colored_messages/result.png) The way the colors look like on Discord is shown in the image below: -![ANSI Colors](https://media.discordapp.net/attachments/739937507768270939/930825555803263016/ANSI-Colors.png) +![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: 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 new file mode 100644 index 00000000..32af146f Binary files /dev/null and b/pydis_site/static/images/content/discord_colored_messages/ansi-colors.png 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 new file mode 100644 index 00000000..a666804e Binary files /dev/null and b/pydis_site/static/images/content/discord_colored_messages/result.png differ -- cgit v1.2.3 From e3067dc451a3a5da02a90d61791c1c7c5731cab0 Mon Sep 17 00:00:00 2001 From: Krypton Date: Thu, 28 Apr 2022 19:17:00 +0200 Subject: Removed `
      `'s and used code block for raw message --- .../python-guides/discord-messages-with-colors.md | 15 +++++++++------ .../discord_colored_messages/ansi-colors.png | Bin 33579 -> 43004 bytes 2 files changed, 9 insertions(+), 6 deletions(-) (limited to 'pydis_site/apps') 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 6845ce76..9a69973e 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 @@ -44,15 +44,18 @@ Here is the list of values you can use to replace `{color}`: * 47: White Let's take an example, I want a bold green colored text with the very 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.
      +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. -Raw message:
      -\`\`\`ansi -\u001b[0;40m\u001b[1;32mThat's some cool formatted text right?
      +Raw message: +````nohighlight +```ansi +\u001b[0;40m\u001b[1;32mThat's some cool formatted text right? or -\u001b[1;40;32mThat's some cool formatted text right?
      -\`\`\` +\u001b[1;40;32mThat's some cool formatted text right? +``` +```` Result: 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 32af146f..d7176393 100644 Binary files a/pydis_site/static/images/content/discord_colored_messages/ansi-colors.png and b/pydis_site/static/images/content/discord_colored_messages/ansi-colors.png differ -- cgit v1.2.3 From 5202bcda5d79f203a67b59990f0ed3d6c87044b2 Mon Sep 17 00:00:00 2001 From: Krypton Date: Thu, 28 Apr 2022 19:34:41 +0200 Subject: Sentences check --- .../resources/guides/python-guides/discord-messages-with-colors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') 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 9a69973e..f4a747c9 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 @@ -11,7 +11,7 @@ To send colored text in a code block you need to first specify the `ansi` langua ``` *`\u001b` is the unicode escape for ESCAPE/ESC, meant to be used in the source of your bot (see ).* ***If you wish to send colored text without using your bot you need to copy the character from the website.*** -After you've written this, you can type and text you wish, and if you want to reset the color back to normal, then you need to use `\u001b[0m` as prefix. +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. Here is the list of values you can use to replace `{format}`: -- cgit v1.2.3 From 8f433372ce889b9a61355ca64a23ca11417d920f Mon Sep 17 00:00:00 2001 From: Krypton Date: Thu, 28 Apr 2022 19:37:42 +0200 Subject: Fixed image not at new line --- .../resources/guides/python-guides/discord-messages-with-colors.md | 1 + 1 file changed, 1 insertion(+) (limited to 'pydis_site/apps') 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 f4a747c9..e414ff39 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 @@ -62,6 +62,7 @@ Result: ![Background and text color result](/static/images/content/discord_colored_messages/result.png) 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: -- cgit v1.2.3 From d25d4ed61c6cb4b8ee310e1647ab91fdd8fe83be Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Thu, 28 Apr 2022 11:19:05 -0700 Subject: Don't do JSON as a database, kids. (#700) Migrate no-JSON-as-a-database pinned message to site --- .../python-guides/why-not-json-as-database.md | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/why-not-json-as-database.md (limited to 'pydis_site/apps') 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 new file mode 100644 index 00000000..ae34c2b4 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/why-not-json-as-database.md @@ -0,0 +1,28 @@ +--- +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/ +--- + +JSON, quite simply, is not a database. It's not designed to be a data storage format, +rather a wayof transmitting data over a network. It's also often used as a way of doing configuration files for programs. + +There is no redundancy built in to JSON. JSON is just a format, and Python has libraries for it +like json and ujson that let you load and dump it, sometimes to files, but that's all it does, write data to a file. +There is no sort of DBMS (Database Management System), which means no sort of sophistication in how the data is stored, +or built in ways to keep it safe and backed up, there's no built in encryption either - bear in mind +in larger applications encryption may be necessary for GDPR/relevant data protection regulations compliance. + +JSON, unlike relational databases, has no way to store relational data, +which is a very commonly needed way of storing data. +Relational data, as the name may suggest, is data that relates to other data. +For example if you have a table of users and a table of servers, the server table will probably have an owner field, +where you'd reference a user from the users table. (**This is only relevant for relational data**). + +JSON is primarily a KV (key-value) format, for example `{"a": "b"}` where `a` is the key and `b` is the value, +but what if you want to search not by that key but by a sub-key? Well, instead of being able to quickly use `var[key]`, +which in a Python dictionary has a constant return time (for more info look up hash tables), +you now have to iterate through every object in the dictionary and compare to find what you're looking for. +Most relational database systems, like MySQL, MariaDB, and PostgreSQL have ways of indexing secondary fields +apart from the primary key so that you can easily search by multiple attributes. -- cgit v1.2.3 From ad41d8d2a58b73bd4402f945f0ae8fcc7b49bda4 Mon Sep 17 00:00:00 2001 From: Krypton Date: Thu, 28 Apr 2022 22:20:52 +0200 Subject: Using hyperlink for gist link at the bottom --- .../resources/guides/python-guides/discord-messages-with-colors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') 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 e414ff39..62ff61f9 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 @@ -65,4 +65,4 @@ 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: +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)**. -- cgit v1.2.3 From ebdba4488b7def58614dc221215a2430d6df6ecf Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Wed, 4 May 2022 20:00:15 +0400 Subject: Add recommended hosting service page (#712) * add article discussing free hosting services * add hsoting and VPS services article * Merge branch 'pinned_messages' of https://github.com/Diabolical5777/site into pinned_messages * Delete free_hosting_services.md * Update pydis_site/apps/content/resources/guides/python-guides/vps_services.md Co-authored-by: Bluenix * Update pydis_site/apps/content/resources/guides/python-guides/vps_services.md Co-authored-by: Bluenix * Update pydis_site/apps/content/resources/guides/python-guides/vps_services.md Co-authored-by: Bluenix * add vps and hosting service guide * Update pydis_site/apps/content/resources/guides/python-guides/vps_services.md Co-authored-by: Bluenix * add article on VPS and free hosting services * Update vps_services.md * Update vps_services.md Co-authored-by: Bluenix Co-authored-by: ChrisJL --- .../resources/guides/python-guides/vps_services.md | 58 ++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/vps_services.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/vps_services.md b/pydis_site/apps/content/resources/guides/python-guides/vps_services.md new file mode 100644 index 00000000..710fd914 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/vps_services.md @@ -0,0 +1,58 @@ +--- +title: VPS and Free Hosting Service for Discord bots +description: This article lists recommended VPS services and covers the disasdvantages of utilising a free hosting service to run a discord bot. +toc: 2 +--- + +## Recommended VPS services + +If you need to run your bot 24/7 (with no downtime), you should consider using a virtual private server (VPS). Here is a list of VPS services that are sufficient for running Discord bots. + +* Europe + * [netcup](https://www.netcup.eu/) + * Germany & Austria data centres. + * Great affiliate program. + * [Yandex Cloud](https://cloud.yandex.ru/) + * Vladimir, Ryazan, and Moscow region data centres. + * [Scaleway](https://www.scaleway.com/) + * France data centre. + * [Time 4 VPS](https://www.time4vps.eu/) + * Lithuania data centre. +* US + * [GalaxyGate](https://galaxygate.net/) + * New York data centre. + * Great affiliate program. +* Global + * [Linode](https://www.linode.com/) + * [Digital Ocean](https://www.digitalocean.com/) + * [OVHcloud](https://www.ovhcloud.com/) + * [Vultr](https://www.vultr.com/) + + +## Why not to use free hosting services for bots? +While these may seem like nice and free services, it has a lot more caveats than you may think. For example, the drawbacks of using common free hosting services to host a discord bot are discussed below. + +### Replit + +- The machines are super underpowered, resulting in your bot lagging a lot as it gets bigger. + +- You need to run a webserver alongside your bot to prevent it from being shut off. This uses extra machine power. + +- Repl.it uses an ephemeral file system. This means any file you saved through your bot will be overwritten when you next launch. + +- They use a shared IP for everything running on the service. +This one is important - if someone is running a user bot on their service and gets banned, everyone on that IP will be banned. Including you. + +### Heroku +- Bots are not what the platform is designed for. Heroku is designed to provide web servers (like Django, Flask, etc). This is why they give you a domain name and open a port on their local emulator. + +- Heroku's environment is heavily containerized, making it significantly underpowered for a standard use case. + +- Heroku's environment is volatile. In order to handle the insane amount of users trying to use it for their own applications, Heroku will dispose your environment every time your application dies unless you pay. + +- Heroku has minimal system dependency control. If any of your Python requirements need C bindings (such as PyNaCl + binding to libsodium, or lxml binding to libxml), they are unlikely to function properly, if at all, in a native + environment. As such, you often need to resort to adding third-party buildpacks to facilitate otherwise normal + CPython extension functionality. (This is the reason why voice doesn't work natively on heroku) + +- Heroku only offers a limited amount of time on their free programme for your applications. If you exceed this limit, which you probably will, they'll shut down your application until your free credit resets. -- cgit v1.2.3 From 56648f34bb48b40668a4ae48f4117fcbaaadea23 Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sat, 7 May 2022 21:52:57 -0400 Subject: Add 'Next steps' section to ends of the setup guides --- .../resources/guides/pydis-guides/contributing/bot.md | 12 ++++++++---- .../guides/pydis-guides/contributing/sir-lancebot.md | 6 ++++++ .../resources/guides/pydis-guides/contributing/site.md | 9 +++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 0f783ef6..e98db596 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -543,10 +543,7 @@ Now that you have everything setup, it is finally time to make changes to the bo #### Working with Git -If you have not yet [read the contributing guidelines](../contributing-guidelines), now is a good time. -Contributions that do not adhere to the guidelines may be rejected. - -Notably, version control of our projects is done using Git and Github. +Version control of our projects is done using Git and Github. It can be intimidating at first, so feel free to ask for any help in the server. [**Click here to see the basic Git workflow when contributing to one of our projects.**](../working-with-git/) @@ -637,4 +634,11 @@ The following is a list of all available environment variables used by the bot: | `METABASE_USERNAME` | When you wish to interact with Metabase | The username for a Metabase admin account. | `METABASE_PASSWORD` | When you wish to interact with Metabase | The password for a Metabase admin account. +--- + +# Next steps +Now that you have everything setup, it is finally time to make changes to the bot! If you have not yet read the [contributing guidelines](../contributing-guidelines.md), now is a good time. Contributions that do not adhere to the guidelines may be rejected. + +If you're not sure where to go from here, our [detailed walkthrough](../#2-set-up-the-project) is for you. + Have fun! 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 1621abb6..f5f1ae4c 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 @@ -110,5 +110,11 @@ After installing project dependencies use the poetry command `poetry run task st ```shell $ poetry run task start ``` +--- + +# Next steps +Now that you have everything setup, it is finally time to make changes to the bot! If you have not yet read the [contributing guidelines](../contributing-guidelines.md), now is a good time. Contributions that do not adhere to the guidelines may be rejected. + +If you're not sure where to go from here, our [detailed walkthrough](../#2-set-up-the-project) is for you. Have fun! diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md index 7eda027a..00115516 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md @@ -166,3 +166,12 @@ The website is configured through the following environment variables: - **`STATIC_ROOT`**: The root in which `python manage.py collectstatic` collects static files. Optional, defaults to `/app/staticfiles` for the standard Docker deployment. + +--- + +# Next steps +Now that you have everything setup, it is finally time to make changes to the site! If you have not yet read the [contributing guidelines](../contributing-guidelines.md), now is a good time. Contributions that do not adhere to the guidelines may be rejected. + +If you're not sure where to go from here, our [detailed walkthrough](../#2-set-up-the-project) is for you. + +Have fun! -- cgit v1.2.3 From 2b45c6c0b13b1183051659b7ea3660b1d6efc90b Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sat, 7 May 2022 22:57:24 -0400 Subject: Add reminders at tops of set-up guides to fork and clone --- .../resources/guides/pydis-guides/contributing/bot.md | 2 ++ .../guides/pydis-guides/contributing/sir-lancebot.md | 12 +++++++----- .../resources/guides/pydis-guides/contributing/site.md | 7 ++++--- 3 files changed, 13 insertions(+), 8 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index e98db596..ad446cc8 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -5,6 +5,8 @@ icon: fab fa-github toc: 3 --- The purpose of this guide is to get you a running local version of [the Python bot](https://github.com/python-discord/bot). +You should have already forked the repository and cloned it to your local machine. If not, check out our [detailed walkthrough](../#1-fork-and-clone-the-repo). + This page will focus on the quickest steps one can take, with mentions of alternatives afterwards. --- 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 f5f1ae4c..c9566d23 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 @@ -5,10 +5,11 @@ icon: fab fa-github toc: 1 --- -> Before contributing, please ensure you read the [contributing guidelines](../contributing-guidelines) in full. +You should have already forked the [`sir-lancebot`](https://github.com/python-discord/sir-lancebot) repository and cloned it to your local machine. If not, check out our [detailed walkthrough](../#1-fork-and-clone-the-repo). ---- -# Requirements +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/) - [Poetry](https://github.com/python-poetry/poetry#installation) - [Git](https://git-scm.com/downloads) @@ -16,6 +17,8 @@ toc: 1 - [MacOS Installer](https://git-scm.com/download/mac) or `brew install git` - [Linux](https://git-scm.com/download/linux) +--- + ## Using Gitpod Sir Lancebot can be edited and tested on Gitpod. Gitpod will automatically install the correct dependencies and Python version, so you can get straight to coding. @@ -42,8 +45,7 @@ The requirements for Docker are: --- # Development Environment -1. Once you have your fork, you will need to [**clone the repository to your computer**](../cloning-repository). -2. After cloning, proceed to [**install the project's dependencies**](../installing-project-dependencies). (This is not required if using Docker) +If you aren't using Docker, you will need to [install the project's dependencies](../installing-project-dependencies) yourself. --- # Test Server and Bot Account diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md index 00115516..520e41ad 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md @@ -5,7 +5,9 @@ icon: fab fa-github toc: 1 --- -# Requirements +You should have already forked the [`site`](https://github.com/python-discord/site) repository and cloned it to your local machine. If not, check out our [detailed walkthrough](../#1-fork-and-clone-the-repo). + +### Requirements - [Python 3.9](https://www.python.org/downloads/) - [Poetry](https://python-poetry.org/docs/#installation) @@ -29,8 +31,7 @@ Without Docker: --- # Development environment -1. [Clone your fork to a local project directory](../cloning-repository/) -2. [Install the project's dependencies](../installing-project-dependencies/) +[Install the project's dependencies](../installing-project-dependencies/) ## Without Docker -- cgit v1.2.3 From 0cce77e26b19949ed14da740f3bb9ed843af4255 Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 8 May 2022 12:06:26 -0400 Subject: Add note that staff members can create feature branches --- pydis_site/apps/content/resources/guides/pydis-guides/contributing.md | 2 ++ 1 file changed, 2 insertions(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index bed2ffa1..d67a827f 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -92,6 +92,8 @@ If you are here looking for the answer to a specific question, check out the sub ### 1. Fork and clone the repo GitHub is a website based on Git that stores project files in the cloud. We use GitHub as a central place for sending changes, reviewing others' changes, and communicating with each other. You'll need to create a copy under your own GitHub account, a.k.a. "fork" it. You'll make your changes to this copy, which can then later be merged into the Python Discord repository. +*Note: Members of the Python Discord staff can create feature branches directly on the repo without forking it.* + Check out our [**guide on forking a GitHub repo**](./forking-repository/). Now that you have your own fork you need to be able to make changes to the code. You can clone the repo to your local machine, commit changes to it there, then push those changes to GitHub. -- cgit v1.2.3 From 2470557cb6f4ce9eb10bdf1759f67b03e1506b6e Mon Sep 17 00:00:00 2001 From: Cam Caswell Date: Sun, 8 May 2022 12:33:59 -0400 Subject: Add Making Changes step --- .../apps/content/resources/guides/pydis-guides/contributing.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index d67a827f..6231fe87 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -126,7 +126,12 @@ The first step to any new contribution is an issue describing a problem with the Don't move forward until your issue is approved by a Core Developer. Issues are not guaranteed to be approved so your work may be wasted. {: .notification .is-warning } -### 5. Open a pull request +### 5. Make changes +Now it is time to make the changes to fulfill your approved issue. You should create a new Git branch for your feature; that way you can keep your main branch up to date with ours and even work on multiple features at once in separate branches. + +This is a good time to review [how to write good commit messages](./contributing-guidelines/commit-messages) if you haven't already. + +### 6. Open a pull request After your issue has been approved and you've written your code and tested it, it's time to open a pull request. Pull requests are a feature in GitHub; you can think of them as asking the project maintainers to accept your changes. This gives other contributors a chance to review your code and make any needed changes before it's merged into the main branch of the project. Check out our [**Pull Request Guide**](./pull-requests/) for help with opening a pull request and going through the review process. -- cgit v1.2.3 From 5f18f0b1aa16be4c2e1b375a0ca0bd4089462dcc Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Mon, 9 May 2022 11:54:06 -0500 Subject: Add video type to Python Morsels resoure Adding the video type at the request of Trey Hunner. --- pydis_site/apps/resources/resources/python_morsels.yaml | 1 + 1 file changed, 1 insertion(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/python_morsels.yaml b/pydis_site/apps/resources/resources/python_morsels.yaml index bbc8133b..4cdff36b 100644 --- a/pydis_site/apps/resources/resources/python_morsels.yaml +++ b/pydis_site/apps/resources/resources/python_morsels.yaml @@ -17,3 +17,4 @@ tags: - intermediate type: - interactive + - video -- cgit v1.2.3 From 512f9ae27c15a23da2e043995c58ae054a14461f Mon Sep 17 00:00:00 2001 From: Steele Date: Mon, 13 Jun 2022 18:56:19 -0400 Subject: Add The Algorithms to resources --- .../resources/resources/the_algorithms_github.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 pydis_site/apps/resources/resources/the_algorithms_github.yaml (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/the_algorithms_github.yaml b/pydis_site/apps/resources/resources/the_algorithms_github.yaml new file mode 100644 index 00000000..10052fd3 --- /dev/null +++ b/pydis_site/apps/resources/resources/the_algorithms_github.yaml @@ -0,0 +1,18 @@ +description: A git repository of Python implementations of many of the algorithms taught in algorithm + and data structure courses, as well as algorithms for neural networks, block chains, and compression. This is + a great resource for students wanting to see algorithms implemented in a familiar language. +name: The Algorithms +title_url: https://github.com/TheAlgorithms/Python +tags: + topics: + - algorithms and data structures + - data science + - security + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tutorial + -- cgit v1.2.3 From b27f619b6a56ee40ce7d68c9e38beb8aec6120e4 Mon Sep 17 00:00:00 2001 From: Steele Date: Mon, 13 Jun 2022 18:58:08 -0400 Subject: Remove Atom, which will no longer be supported --- pydis_site/apps/resources/resources/atom.yaml | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 pydis_site/apps/resources/resources/atom.yaml (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/atom.yaml b/pydis_site/apps/resources/resources/atom.yaml deleted file mode 100644 index 26e125b1..00000000 --- a/pydis_site/apps/resources/resources/atom.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: A free Electron-based editor, a "hackable text editor for the 21st century", maintained - by the GitHub team. -name: Atom -title_url: https://atom.io/ -tags: - topics: - - general - payment_tiers: - - free - difficulty: - - beginner - - intermediate - type: - - tool -- cgit v1.2.3 From 92a6872e548c851d06c4cdbc9dd9746224293c84 Mon Sep 17 00:00:00 2001 From: Steele Date: Mon, 13 Jun 2022 19:00:07 -0400 Subject: Update vcokltfre's discord bot tutorial --- pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') 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 61a7b6f6..12f2a154 100644 --- a/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml +++ b/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml @@ -1,4 +1,4 @@ -description: This tutorial, written by Python Discord staff member vcokltfre, +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 -- cgit v1.2.3 From ae42c30c2ff53747cde98a5aa4b40f126bc10da9 Mon Sep 17 00:00:00 2001 From: Steele Date: Mon, 13 Jun 2022 19:02:08 -0400 Subject: Add padlock as icon for Security resources --- pydis_site/apps/resources/templatetags/get_category_icon.py | 1 + 1 file changed, 1 insertion(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/templatetags/get_category_icon.py b/pydis_site/apps/resources/templatetags/get_category_icon.py index 71f1393f..97b5e0a7 100644 --- a/pydis_site/apps/resources/templatetags/get_category_icon.py +++ b/pydis_site/apps/resources/templatetags/get_category_icon.py @@ -21,6 +21,7 @@ _ICONS = { "Paid": "fa-dollar-sign", "Podcast": "fa-microphone-alt", "Project Ideas": "fa-lightbulb-o", + "Security": "lock-alt", "Software Design": "fa-paint-brush", "Subscription": "fa-credit-card", "Testing": "fa-vial", -- cgit v1.2.3 From 958408461607e20f888fdef0eb16fa7bd05b8568 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Mon, 20 Jun 2022 16:11:30 +0400 Subject: Update subclassing_bot.md --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 -- 1 file changed, 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 381f25f1..93d65b35 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -56,5 +56,3 @@ async def example(ctx): # In this case, this will print Hello World! ``` With either of the above examples, you are not required to change any of the existing or future code, it is identical to code done without subclassing bot. - -To access the custom bot attributes set in the subclass, in the main bot file (in the context of the above example), `bot.variable_name` would be used, and as for cogs, it would be `self.bot.variable_name`. For the custom methods set, in the main file it would be `bot.custom_method()` in the main file and `self.bot.custom_method()` in a cog file. -- cgit v1.2.3 From 4ec0fb3e6102c0f5a99c51afcec38eb5d0dfa6ae Mon Sep 17 00:00:00 2001 From: Blue-Puddle Date: Tue, 28 Jun 2022 06:23:50 +0100 Subject: added discord-app-commands.md --- .../guides/python-guides/discord-app-commands.md | 451 +++++++++++++++++++++ 1 file changed, 451 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md new file mode 100644 index 00000000..79d224a1 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md @@ -0,0 +1,451 @@ +--- +title: Slash Commands with discord.py! +description: A simple guide to creating slash commands within discord.py! +--- +# DISCORD.PY RESUMPTION CHANGES + +--- + +Upon resumption of the most popular discord API wrapper library for python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with addition of features to the library. Some additions to the library are -> Buttons support, Select Menus Support, Forms (AKA Modals), Slash Commands (AKA Application Commands) and a bunch of more handy features! All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py gist regarding resumption can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6). + +# Why this gist? + +--- + +This Gist is being created as an update to slash commands (app commands) with explanation and examples. This Gist mainly focuses on **SLASH COMMANDS** for discord.py 2.0 (and above)! + +# What Are Slash Commands? + +--- + +Slash Commands are the exciting way to build and interact with bots on Discord. With Slash Commands, all you have to do is type / and you're ready to use your favourite bot. You can easily see all the commands a bot has, and validation and error handling help you get the command right the first time. + +# Install the latest version for discord.py + +--- +To use slash commands with discord.py. The latest up-to-date version has to be installed. Make sure that the version is 2.0 or above! +And make sure to uninstall any third party libraries that support slash commands for discord.py (if any) as a few of them [monkey patch](https://en.wikipedia.org/wiki/Monkey_patch#:~:text=A%20monkey%20patch%20is%20a,running%20instance%20of%20the%20program) discord.py! + +The latest and up-to-date usable discord.py version can be installed using `pip install -U git+https://github.com/Rapptz/discord.py`. + +If you get an error such as: `'git' is not recognized...`. [Install git](https://git-scm.com/downloads) for your platform. Go through the required steps for installing git and make sure to enable the `add to path` option while in the installation wizard. After installing git you can run `pip install -U git+https://github.com/Rapptz/discord.py` again to install discord.py 2.0. +**BEFORE MIGRATING TO DISCORD.PY 2.0, PLEASE READ THE CONSEQUENCES OF THE UPDATE [HERE](https://discordpy.readthedocs.io/en/latest/migrating.html)**. + +# Basic Structure for Discord.py Slash Commands! + +--- + +### Note that Slash Commands in discord.py are also referred to as **Application Commmands** and **App Commands** and every *interaction* is a *webhook*. +Slash commands in discord.py are held by a container, [CommandTree](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=commandtree#discord.app_commands.CommandTree). A command tree is required to create slash commands in discord.py. This command tree provides a `command` method which decorates an asynchronous function indicating to discord.py that the decorated function is intended to be a slash command. This asynchronous function expects a default argument which acts as the interaction which took place that invoked the slash command. This default argument is an instance of the **Interaction** class from discord.py. Further up, the command logic takes over the behaviour of the slash command. + +# Fundamentals for this gist! + +--- + + +The fundamental for this gist will remain to be the `setup_hook`. The `setup_hook` is a special asynchronous method of the Client and Bot class which can be overwritten to perform numerous tasks. This method is safe to use as it is always triggered before any events are dispatched, i.e. this method is triggered before the *IDENTIFY* payload is sent to the discord gateway. +Note that methods of the Bot class such as `change_presence` will not work in setup_hook as the current application does not have an active connection to the gateway at this point. + +__**FOLLOWING IS THE EXAMPLE OF HOW A `SETUP_HOOK` FUNCTION CAN BE DEFINED**__ + +```python +import discord + +'''This is one way of creating a "setup_hook" method''' + +class SlashClient(discord.Client): + def __init__(self) -> None: + super().__init__(intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + #perform tasks + +'''Another way of creating a "setup_hook" is as follows''' + +client = discord.Client(intents=discord.Intents.default()) +async def my_setup_hook() -> None: + #perform tasks + +client.setup_hook = my_setup_hook +``` + +# Basic Slash Command application using discord.py. + +#### The `CommandTree` class resides within the `app_commands` of discord.py package. +--- + +## Slash Command Application with a Client + +```python +import discord + +class SlashClient(discord.Client): + def __init__(self) -> None: + super().__init__(intents=discord.Intents.default()) + self.tree = discord.app_commands.CommandTree(self) + + async def setup_hook(self) -> None: + self.tree.copy_global_to(guild=discord.Object(id=12345678900987654)) + await self.tree.sync() + +client = SlashClient() + +@client.tree.command(name="ping", description="...") +async def _ping(interaction: discord.Interaction) -> None: + await interaction.response.send_message("pong") + +client.run("token") +``` + + +__**EXPLANATION**__ + +- `import discord` imports the **discord.py** package. +- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid. +- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commaands. +- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. +- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`. +- Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker. +- And the classic old `client.run("token")` is used to connect the client to the discord gateway. +- Note that the `send_message` is a method of the `InteractionResponse` class and `interaction.response` in this case is an instance of the `InteractionResponse` object. The `send_message` method will not function if the response is not sent within 3 seconds of command invocation. I will discuss about how to handle this issue later following the gist. + +## Slash Command Application with a Bot + +```python +import discord + +class SlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix=".", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + self.tree.copy_global_to(guild=discord.Object(id=12345678900987654)) + await self.tree.sync() + +bot = SlashBot() + +@bot.tree.command(name="ping", description="...") +async def _ping(interaction: discord.Interaction) -> None: + await interaction.response.send_message("pong") + +bot.run("token") +``` + +The above example shows a basic slash commands within discord.py using the Bot class. + +__**EXPLANATION**__ + +Most of the explanation is the same as the prior example which featured `SlashClient` which was a subclass of **discord.Client**. Though some minor changes are discussed below. + +- The `SlashBot` class now subclasses `discord.ext.commands.Bot` following the passing in of the required arguments to its `__init__` method. +- `discord.ext.commands.Bot` already consists of an instance of the `CommandTree` class which can be accessed using the `tree` property. + +# Slash Commands within a Cog! + +--- + +A cog is a collection of commands, listeners, and optional state to help group commands together. More information on them can be found on the [Cogs](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs) page. + +## An Example to using cogs with discord.py for slash commands! + +```python +import discord +from discord.ext import commands +from discord import app_commands + +class MySlashCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @app_commands.command(name="ping", description="...") + async def _ping(self, interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +class MySlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix="!", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + await self.add_cog(MySlashCog(self)) + await self.tree.copy_global_to(discord.Object(id=123456789098765432)) + await self.tree.sync() + +bot = MySlashBot() + +bot.run("token") +``` + +__**EXPLANATION**__ + +- Firstly, `import discord` imports the **discord.py** package. `from discord import app_commands` imports the `app_commands` directory from the **discord.py** root directory. `from discord.ext import commands` imports the commands extension. +- Further up, `class MySlashCog(commands.Cog)` is a class subclassing the `Cog` class. You can read more about this [here](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs). +- `def __init__(self, bot: commands.Bot): self.bot = bot` is the constructor method of the class that is always run when the class is instantiated and that is why we pass in a **Bot** object whenever we create an instance of the cog class. +- Following up is the `@app_commands.command(name="ping", description="...")` decorator. This decorator basically functions the same as a `bot.tree.command` but since the cog currently does not have a **bot**, the `app_commands.command` decorator is used instead. The next two lines follow the same structure for slash commands with **self** added as the first parameter to the function as it is a method of a class. +- The next up lines are mostly the same. +- Talking about the first line inside the `setup_hook` is the `add_cog` method of the **Bot** class. And since **self** acts as the **instance** of the current class, we use **self** to use the `add_cog` method of the **Bot** class as we are inside a subclassed class of the **Bot** class. Then we pass in **self** to the `add_cog` method as the `__init__` function of the **MySlashCog** cog accepts a `Bot` object. +- After that we instantiate the `MySlashBot` class and run the bot using the **run** method which executes our setup_hook function and our commands get loaded and synced. The bot is now ready to use! + +# An Example to using groups with discord.py for slash commands! + +--- + +## An example with optional group! + +```python +import discord +from discord.ext import commands +from discord import app_commands + +class MySlashGroupCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + #-------------------------------------------------------- + group = app_commands.Group(name="uwu", description="...") + #-------------------------------------------------------- + + @app_commands.command(name="ping", description="...") + async def _ping(self, interaction: discord.) -> None: + await interaction.response.send_message("pong!") + + @group.command(name="command", description="...") + async def _cmd(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message("uwu") + +class MySlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix="!", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + await self.add_cog(MySlashGroupCog(self)) + await self.tree.copy_global_to(discord.Object(id=123456789098765432)) + await self.tree.sync() + +bot = MySlashBot() + +bot.run("token") +``` + +__**EXPLANATION**__ +- The only difference used here is `group = app_commands.Group(name="uwu", description="...")` and `group.command`. `app_commands.Group` is used to initiate a group while `group.command` registers a command under a group. For example, the ping command can be run using **/ping** but this is not the case for group commands. They are registered with the format of `group_name command_name`. So here, the **command** command of the **uwu** group would be run using **/uwu command**. Note that only group commands can have a single space between them. + +--- + +## An example with a **Group** subclass! + +```python +import discord +from discord.ext import commands +from discord import app_commands + +class MySlashGroup(commands.GroupCog, name="uwu"): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + super().__init__() + + @app_commands.command(name="ping", description="...") + async def _ping(self, interaction: discord.) -> None: + await interaction.response.send_message("pong!") + + @app_commands.command(name="command", description="...") + async def _cmd(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message("uwu") + +class MySlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix="!", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + await self.add_cog(MySlashGroup(self)) + await self.tree.copy_global_to(discord.Object(id=123456789098765432)) + await self.tree.sync() + +bot = MySlashBot() + +bot.run("token") +``` + +__**EXPLANATION**__ +- The only difference here too is that the `MySlashGroup` class directly subclasses the **GroupCog** class from discord.ext.commands which automatically registers all the methods within the group class to be commands of that specific group. So now, the commands such as `ping` can be run using **/uwu ping** and `command` using **/uwu command**. + + +# Some common methods and features used for slash commands. + +--- + +### A common function used for slash commands is the `describe` function. This is used to add descriptions to the arguments of a slash command. The command function can decorated with this function. It goes by the following syntax as shown below. + +```python +from discord.ext import commands +from discord import app_commands +import discord + +bot = commands.Bot(command_prefix=".", intents=discord.Intents.default()) +#sync the commands + +@bot.tree.command(name="echo", description="...") +@app_commands.describe(text="The text to send!", channel="The channel to send the message in!") +async def _echo(interaction: discord.Interaction, text: str, channel: discord.TextChannel=None): + channel = interaction.channel or channel + await channel.send(text) +``` + +### Another common issue that most people come across is the time duraction of sending a message with `send_message`. This issue can be tackled by deferring the interaction response using the `defer` method of the `InteractionResponse` class. An example for fixing this issue is shown below. + +```python +import discord +from discord.ext import commands +import asyncio + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync the commands + +@bot.tree.command(name="time", description="...") +async def _time(interaction: discord.Interaction, time_to_wait: int): + # ------------------------------------------------------------- + await interaction.response.defer(ephemeral=True, thinking=True) + # ------------------------------------------------------------- + await interaction.edit_original_message(content=f"I will notify you after {time_to_wait} seconds have passed!") + await asyncio.sleep(time_to_wait) + await interaction.edit_original_message(content=f"{interaction.user.mention}, {time_to_wait} seconds have already passed!") +``` + +# Checking for Permissions and Roles! + +--- + +To add a permissions check to a command, the methods are imported through `discord.app_commands.checks`. To check for a member's permissions, the function can be decorated with the [discord.app_commands.checks.has_permissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=has_permissions#discord.app_commands.checks.has_permissions) method. An example to this as follows. + +```py +from discord import app_commands +from discord.ext import commands +import discord + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync commands + +@bot.tree.command(name="ping") +@app_commands.checks.has_permissions(manage_messages=True, manage_channels=True) #example permissions +async def _ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +``` + +If the check fails, it will raise a `MissingPermissions` error which can be handled within an app commands error handler! I will discuss about making an error handler later in the gist. All the permissions can be found [here](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discord%20permissions#discord.Permissions). + +Other methods that you can decorate the commands with are - +- `bot_has_permissions` | This checks if the bot has the required permissions for executing the slash command. This raises a [BotMissingPermissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.BotMissingPermissions) exception. +- `has_role` | This checks if the slash command user has the required role or not. Only **ONE** role name or role ID can be passed to this. If the name is being passed, make sure to have the exact same name as the role name. This raises a [MissingRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingRole) exception. +- To pass in several role names or role IDs, `has_any_role` can be used to decorate a command. This raises two exceptions -> [MissingAnyRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingAnyRole) and [NoPrivateMessage](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.NoPrivateMessage) + + +# Adding cooldowns to slash commands! + +--- + +Slash Commands within discord.py can be applied cooldowns to in order to prevent spamming of the commands. This can be done through the `discord.app_commands.checks.cooldown` method which can be used to decorate a slash command function and register a cooldown to the function. This raises a [CommandOnCooldown](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=checks%20cooldown#discord.app_commands.CommandOnCooldown) exception if the command is currently on cooldown. +An example is as follows. + +```python +from discord.ext import commands +import discord + +class Bot(commands.Bot): + def __init__(self): + super().__init__(command_prefix="uwu", intents=discord.Intents.all()) + + + async def setup_hook(self): + self.tree.copy_global_to(guild=discord.Object(id=12345678909876543)) + await self.tree.sync() + + +bot = Bot() + +@bot.tree.command(name="ping") +# ----------------------------------------- +@discord.app_commands.checks.cooldown(1, 30) +# ----------------------------------------- +async def ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +bot.run("token") +``` + +__**EXPLANATION**__ +- The first argument the `cooldown` method takes is the amount of times the command can be run in a specific period of time. +- The second argument it takes is the period of time in which the command can be run the specified number of times. +- The `CommandOnCooldown` exception can be handled using an error handler. I will discuss about making an error handler for slash commands later in the gist. + + +# Handling errors for slash commands! + +--- + +The Slash Commands exceptions can be handled by overwriting the `on_error` method of the `CommandTree`. The error handler takes two arguments. The first argument is the `Interaction` that took place when the error occurred and the second argument is the error that occurred when the slash commands was invoked. The error is an instance of [discord.app_commands.AppCommandError](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=appcommanderror#discord.app_commands.AppCommandError) which is a subclass of [DiscordException](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discordexception#discord.DiscordException). +An example to creating an error handler for slash commands is as follows. + +```python +from discord.ext import commands +from discord import app_commands +import discord + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync commands + +@bot.tree.command(name="ping") +app_commands.checks.cooldown(1, 30) +async def ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +async def on_tree_error(interaction: discord.Interaction, error: app_commands.AppCommandError): + if isinstance(error, app_commands.CommandOnCooldown): + return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!") + + elif isinstance(..., ...): + ... + + else: + raise error + +bot.tree.on_error = on_tree_error + +bot.run("token") +``` + +__**EXPLANATION**__ + +First we create a simple function named as `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here I have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. After creating the error handler function, we set the function as the error handler for the slash commands. Here, `bot.tree.on_error = on_tree_error` overwrites the default `on_error` method of the **CommandTree** class with our custom error handler which has been named as `on_tree_error` here. + +### Creating an error handler for a specific error! + +```python +from discord.ext import commands +from discord import app_commands +import discord + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync commands + +@bot.tree.command(name="ping") +app_commands.checks.cooldown(1, 30) +async def ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +@ping.error +async def ping_error(interaction: discord.Interaction, error: app_commands.AppCommandError): + if isinstance(error, app_commands.CommandOnCooldown): + return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!") + + elif isinstance(..., ...): + ... + + else: + raise error + +bot.run("token") +``` + +__**EXPLANATION**__ + +Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. Please do not call the `error` method. \ No newline at end of file -- cgit v1.2.3 From 1e59d608eb5f54b238aa55587a7bc9bfa32346d4 Mon Sep 17 00:00:00 2001 From: Robin <74519799+Robin5605@users.noreply.github.com> Date: Tue, 28 Jun 2022 13:20:06 -0500 Subject: Fix EOF Add a newline at the end of file --- .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md index 79d224a1..57423fa4 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md @@ -448,4 +448,4 @@ bot.run("token") __**EXPLANATION**__ -Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. Please do not call the `error` method. \ No newline at end of file +Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. Please do not call the `error` method. -- cgit v1.2.3 From ce144ef99340339af732d41aa56714022f8a023f Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Tue, 28 Jun 2022 13:47:01 -0500 Subject: Fix trailing whitespaces --- .../guides/python-guides/discord-app-commands.md | 56 +++++++++++----------- 1 file changed, 28 insertions(+), 28 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md index 57423fa4..e6095252 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md @@ -56,12 +56,12 @@ import discord class SlashClient(discord.Client): def __init__(self) -> None: super().__init__(intents=discord.Intents.default()) - + async def setup_hook(self) -> None: #perform tasks '''Another way of creating a "setup_hook" is as follows''' - + client = discord.Client(intents=discord.Intents.default()) async def my_setup_hook() -> None: #perform tasks @@ -83,7 +83,7 @@ class SlashClient(discord.Client): def __init__(self) -> None: super().__init__(intents=discord.Intents.default()) self.tree = discord.app_commands.CommandTree(self) - + async def setup_hook(self) -> None: self.tree.copy_global_to(guild=discord.Object(id=12345678900987654)) await self.tree.sync() @@ -100,11 +100,11 @@ client.run("token") __**EXPLANATION**__ -- `import discord` imports the **discord.py** package. -- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid. -- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commaands. -- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. -- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`. +- `import discord` imports the **discord.py** package. +- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid. +- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commaands. +- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. +- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`. - Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker. - And the classic old `client.run("token")` is used to connect the client to the discord gateway. - Note that the `send_message` is a method of the `InteractionResponse` class and `interaction.response` in this case is an instance of the `InteractionResponse` object. The `send_message` method will not function if the response is not sent within 3 seconds of command invocation. I will discuss about how to handle this issue later following the gist. @@ -117,7 +117,7 @@ import discord class SlashBot(commands.Bot): def __init__(self) -> None: super().__init__(command_prefix=".", intents=discord.Intents.default()) - + async def setup_hook(self) -> None: self.tree.copy_global_to(guild=discord.Object(id=12345678900987654)) await self.tree.sync() @@ -156,20 +156,20 @@ from discord import app_commands class MySlashCog(commands.Cog): def __init__(self, bot: commands.Bot) -> None: self.bot = bot - + @app_commands.command(name="ping", description="...") async def _ping(self, interaction: discord.Interaction): await interaction.response.send_message("pong!") - + class MySlashBot(commands.Bot): def __init__(self) -> None: super().__init__(command_prefix="!", intents=discord.Intents.default()) - + async def setup_hook(self) -> None: await self.add_cog(MySlashCog(self)) await self.tree.copy_global_to(discord.Object(id=123456789098765432)) await self.tree.sync() - + bot = MySlashBot() bot.run("token") @@ -207,20 +207,20 @@ class MySlashGroupCog(commands.Cog): @app_commands.command(name="ping", description="...") async def _ping(self, interaction: discord.) -> None: await interaction.response.send_message("pong!") - + @group.command(name="command", description="...") async def _cmd(self, interaction: discord.Interaction) -> None: await interaction.response.send_message("uwu") - + class MySlashBot(commands.Bot): def __init__(self) -> None: super().__init__(command_prefix="!", intents=discord.Intents.default()) - + async def setup_hook(self) -> None: await self.add_cog(MySlashGroupCog(self)) await self.tree.copy_global_to(discord.Object(id=123456789098765432)) await self.tree.sync() - + bot = MySlashBot() bot.run("token") @@ -246,20 +246,20 @@ class MySlashGroup(commands.GroupCog, name="uwu"): @app_commands.command(name="ping", description="...") async def _ping(self, interaction: discord.) -> None: await interaction.response.send_message("pong!") - + @app_commands.command(name="command", description="...") async def _cmd(self, interaction: discord.Interaction) -> None: await interaction.response.send_message("uwu") - + class MySlashBot(commands.Bot): def __init__(self) -> None: super().__init__(command_prefix="!", intents=discord.Intents.default()) - + async def setup_hook(self) -> None: await self.add_cog(MySlashGroup(self)) await self.tree.copy_global_to(discord.Object(id=123456789098765432)) await self.tree.sync() - + bot = MySlashBot() bot.run("token") @@ -271,7 +271,7 @@ __**EXPLANATION**__ # Some common methods and features used for slash commands. ---- +--- ### A common function used for slash commands is the `describe` function. This is used to add descriptions to the arguments of a slash command. The command function can decorated with this function. It goes by the following syntax as shown below. @@ -353,13 +353,13 @@ import discord class Bot(commands.Bot): def __init__(self): super().__init__(command_prefix="uwu", intents=discord.Intents.all()) - - + + async def setup_hook(self): - self.tree.copy_global_to(guild=discord.Object(id=12345678909876543)) + self.tree.copy_global_to(guild=discord.Object(id=12345678909876543)) await self.tree.sync() - - + + bot = Bot() @bot.tree.command(name="ping") @@ -374,7 +374,7 @@ bot.run("token") __**EXPLANATION**__ - The first argument the `cooldown` method takes is the amount of times the command can be run in a specific period of time. -- The second argument it takes is the period of time in which the command can be run the specified number of times. +- The second argument it takes is the period of time in which the command can be run the specified number of times. - The `CommandOnCooldown` exception can be handled using an error handler. I will discuss about making an error handler for slash commands later in the gist. -- cgit v1.2.3 From d4f717ec186ffeedf7bdeb4991868160f1540b83 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 30 Jun 2022 10:48:33 +0100 Subject: Remove embed validators for deleted messages These caused more harm than they were worth, as every time Discord updated a behaviour of an embed we would get errors and need ot update the validation. Instead we should just accept whatever discord gives us as correct --- .../api/migrations/0083_remove_embed_validation.py | 19 ++ pydis_site/apps/api/models/bot/message.py | 5 +- pydis_site/apps/api/models/utils.py | 172 ---------------- pydis_site/apps/api/tests/test_validators.py | 229 --------------------- 4 files changed, 20 insertions(+), 405 deletions(-) create mode 100644 pydis_site/apps/api/migrations/0083_remove_embed_validation.py delete mode 100644 pydis_site/apps/api/models/utils.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0083_remove_embed_validation.py b/pydis_site/apps/api/migrations/0083_remove_embed_validation.py new file mode 100644 index 00000000..e835bb66 --- /dev/null +++ b/pydis_site/apps/api/migrations/0083_remove_embed_validation.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.14 on 2022-06-30 09:41 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0082_otn_allow_big_solidus'), + ] + + operations = [ + migrations.AlterField( + model_name='deletedmessage', + name='embeds', + field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(), blank=True, help_text='Embeds attached to this message.', size=None), + ), + ] diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index bab3368d..bfa54721 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -7,7 +7,6 @@ from django.utils import timezone from pydis_site.apps.api.models.bot.user import User from pydis_site.apps.api.models.mixins import ModelReprMixin -from pydis_site.apps.api.models.utils import validate_embed class Message(ModelReprMixin, models.Model): @@ -48,9 +47,7 @@ class Message(ModelReprMixin, models.Model): blank=True ) embeds = pgfields.ArrayField( - models.JSONField( - validators=(validate_embed,) - ), + models.JSONField(), blank=True, help_text="Embeds attached to this message." ) diff --git a/pydis_site/apps/api/models/utils.py b/pydis_site/apps/api/models/utils.py deleted file mode 100644 index 859394d2..00000000 --- a/pydis_site/apps/api/models/utils.py +++ /dev/null @@ -1,172 +0,0 @@ -from collections.abc import Mapping -from typing import Any, Dict - -from django.core.exceptions import ValidationError -from django.core.validators import MaxLengthValidator, MinLengthValidator - - -def is_bool_validator(value: Any) -> None: - """Validates if a given value is of type bool.""" - if not isinstance(value, bool): - raise ValidationError(f"This field must be of type bool, not {type(value)}.") - - -def validate_embed_fields(fields: dict) -> None: - """Raises a ValidationError if any of the given embed fields is invalid.""" - field_validators = { - 'name': (MaxLengthValidator(limit_value=256),), - 'value': (MaxLengthValidator(limit_value=1024),), - 'inline': (is_bool_validator,), - } - - required_fields = ('name', 'value') - - for field in fields: - if not isinstance(field, Mapping): - raise ValidationError("Embed fields must be a mapping.") - - if not all(required_field in field for required_field in required_fields): - raise ValidationError( - f"Embed fields must contain the following fields: {', '.join(required_fields)}." - ) - - for field_name, value in field.items(): - if field_name not in field_validators: - raise ValidationError(f"Unknown embed field field: {field_name!r}.") - - for validator in field_validators[field_name]: - validator(value) - - -def validate_embed_footer(footer: Dict[str, str]) -> None: - """Raises a ValidationError if the given footer is invalid.""" - field_validators = { - 'text': ( - MinLengthValidator( - limit_value=1, - message="Footer text must not be empty." - ), - MaxLengthValidator(limit_value=2048) - ), - 'icon_url': (), - 'proxy_icon_url': () - } - - if not isinstance(footer, Mapping): - raise ValidationError("Embed footer must be a mapping.") - - for field_name, value in footer.items(): - if field_name not in field_validators: - raise ValidationError(f"Unknown embed footer field: {field_name!r}.") - - for validator in field_validators[field_name]: - validator(value) - - -def validate_embed_author(author: Any) -> None: - """Raises a ValidationError if the given author is invalid.""" - field_validators = { - 'name': ( - MinLengthValidator( - limit_value=1, - message="Embed author name must not be empty." - ), - MaxLengthValidator(limit_value=256) - ), - 'url': (), - 'icon_url': (), - 'proxy_icon_url': () - } - - if not isinstance(author, Mapping): - raise ValidationError("Embed author must be a mapping.") - - for field_name, value in author.items(): - if field_name not in field_validators: - raise ValidationError(f"Unknown embed author field: {field_name!r}.") - - for validator in field_validators[field_name]: - validator(value) - - -def validate_embed(embed: Any) -> None: - """ - Validate a JSON document containing an embed as possible to send on Discord. - - This attempts to rebuild the validation used by Discord - as well as possible by checking for various embed limits so we can - ensure that any embed we store here will also be accepted as a - valid embed by the Discord API. - - Using this directly is possible, although not intended - you usually - stick this onto the `validators` keyword argument of model fields. - - Example: - - >>> from django.db import models - >>> from pydis_site.apps.api.models.utils import validate_embed - >>> class MyMessage(models.Model): - ... embed = models.JSONField( - ... validators=( - ... validate_embed, - ... ) - ... ) - ... # ... - ... - - Args: - embed (Any): - A dictionary describing the contents of this embed. - See the official documentation for a full reference - of accepted keys by this dictionary: - https://discordapp.com/developers/docs/resources/channel#embed-object - - Raises: - ValidationError: - In case the given embed is deemed invalid, a `ValidationError` - is raised which in turn will allow Django to display errors - as appropriate. - """ - all_keys = { - 'title', 'type', 'description', 'url', 'timestamp', - 'color', 'footer', 'image', 'thumbnail', 'video', - 'provider', 'author', 'fields' - } - one_required_of = {'description', 'fields', 'image', 'title', 'video'} - field_validators = { - 'title': ( - MinLengthValidator( - limit_value=1, - message="Embed title must not be empty." - ), - MaxLengthValidator(limit_value=256) - ), - 'description': (MaxLengthValidator(limit_value=4096),), - 'fields': ( - MaxLengthValidator(limit_value=25), - validate_embed_fields - ), - 'footer': (validate_embed_footer,), - 'author': (validate_embed_author,) - } - - if not embed: - raise ValidationError("Tag embed must not be empty.") - - elif not isinstance(embed, Mapping): - raise ValidationError("Tag embed must be a mapping.") - - elif not any(field in embed for field in one_required_of): - raise ValidationError(f"Tag embed must contain one of the fields {one_required_of}.") - - for required_key in one_required_of: - if required_key in embed and not embed[required_key]: - raise ValidationError(f"Key {required_key!r} must not be empty.") - - for field_name, value in embed.items(): - if field_name not in all_keys: - raise ValidationError(f"Unknown field name: {field_name!r}") - - if field_name in field_validators: - for validator in field_validators[field_name]: - validator(value) diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py index 551cc2aa..8c46fcbc 100644 --- a/pydis_site/apps/api/tests/test_validators.py +++ b/pydis_site/apps/api/tests/test_validators.py @@ -5,7 +5,6 @@ from django.test import TestCase from ..models.bot.bot_setting import validate_bot_setting_name from ..models.bot.offensive_message import future_date_validator -from ..models.utils import validate_embed REQUIRED_KEYS = ( @@ -22,234 +21,6 @@ class BotSettingValidatorTests(TestCase): validate_bot_setting_name('bad name') -class TagEmbedValidatorTests(TestCase): - def test_rejects_non_mapping(self): - with self.assertRaises(ValidationError): - validate_embed('non-empty non-mapping') - - def test_rejects_missing_required_keys(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'unknown': "key" - }) - - def test_rejects_one_correct_one_incorrect(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'provider': "??", - 'title': "" - }) - - def test_rejects_empty_required_key(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'title': '' - }) - - def test_rejects_list_as_embed(self): - with self.assertRaises(ValidationError): - validate_embed([]) - - def test_rejects_required_keys_and_unknown_keys(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'title': "the duck walked up to the lemonade stand", - 'and': "he said to the man running the stand" - }) - - def test_rejects_too_long_title(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'title': 'a' * 257 - }) - - def test_rejects_too_many_fields(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'fields': [{} for _ in range(26)] - }) - - def test_rejects_too_long_description(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'description': 'd' * 4097 - }) - - def test_allows_valid_embed(self): - validate_embed({ - 'title': "My embed", - 'description': "look at my embed, my embed is amazing" - }) - - def test_allows_unvalidated_fields(self): - validate_embed({ - 'title': "My embed", - 'provider': "what am I??" - }) - - def test_rejects_fields_as_list_of_non_mappings(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'fields': ['abc'] - }) - - def test_rejects_fields_with_unknown_fields(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'fields': [ - { - 'what': "is this field" - } - ] - }) - - def test_rejects_fields_with_too_long_name(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'fields': [ - { - 'name': "a" * 257 - } - ] - }) - - def test_rejects_one_correct_one_incorrect_field(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'fields': [ - { - 'name': "Totally valid", - 'value': "LOOK AT ME" - }, - { - 'name': "Totally valid", - 'value': "LOOK AT ME", - 'oh': "what is this key?" - } - ] - }) - - def test_rejects_missing_required_field_field(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'fields': [ - { - 'name': "Totally valid", - 'inline': True, - } - ] - }) - - def test_rejects_invalid_inline_field_field(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'fields': [ - { - 'name': "Totally valid", - 'value': "LOOK AT ME", - 'inline': "Totally not a boolean", - } - ] - }) - - def test_allows_valid_fields(self): - validate_embed({ - 'fields': [ - { - 'name': "valid", - 'value': "field", - }, - { - 'name': "valid", - 'value': "field", - 'inline': False, - }, - { - 'name': "valid", - 'value': "field", - 'inline': True, - }, - ] - }) - - def test_rejects_footer_as_non_mapping(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'title': "whatever", - 'footer': [] - }) - - def test_rejects_footer_with_unknown_fields(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'title': "whatever", - 'footer': { - 'duck': "quack" - } - }) - - def test_rejects_footer_with_empty_text(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'title': "whatever", - 'footer': { - 'text': "" - } - }) - - def test_allows_footer_with_proper_values(self): - validate_embed({ - 'title': "whatever", - 'footer': { - 'text': "django good" - } - }) - - def test_rejects_author_as_non_mapping(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'title': "whatever", - 'author': [] - }) - - def test_rejects_author_with_unknown_field(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'title': "whatever", - 'author': { - 'field': "that is unknown" - } - }) - - def test_rejects_author_with_empty_name(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'title': "whatever", - 'author': { - 'name': "" - } - }) - - def test_rejects_author_with_one_correct_one_incorrect(self): - with self.assertRaises(ValidationError): - validate_embed({ - 'title': "whatever", - 'author': { - # Relies on "dictionary insertion order remembering" (D.I.O.R.) behaviour - 'url': "bobswebsite.com", - 'name': "" - } - }) - - def test_allows_author_with_proper_values(self): - validate_embed({ - 'title': "whatever", - 'author': { - 'name': "Bob" - } - }) - - class OffensiveMessageValidatorsTests(TestCase): def test_accepts_future_date(self): future_date_validator(datetime(3000, 1, 1, tzinfo=timezone.utc)) -- cgit v1.2.3 From 084357dbcc48445262fe078e7cb035d46be02e48 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 30 Jun 2022 10:49:24 +0100 Subject: Remove embed validators from old migrations Since the util file has been deleted, these migrations were referencing a missing file --- pydis_site/apps/api/migrations/0019_deletedmessage.py | 2 +- pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py | 3 +-- pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0019_deletedmessage.py b/pydis_site/apps/api/migrations/0019_deletedmessage.py index 6b848d64..25d04434 100644 --- a/pydis_site/apps/api/migrations/0019_deletedmessage.py +++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py @@ -18,7 +18,7 @@ class Migration(migrations.Migration): ('id', models.BigIntegerField(help_text='The message ID as taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Message IDs cannot be negative.')])), ('channel_id', models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), ('content', models.CharField(help_text='The content of this message, taken from Discord.', max_length=2000)), - ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), help_text='Embeds attached to this message.', size=None)), + ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[]), help_text='Embeds attached to this message.', size=None)), ('author', models.ForeignKey(help_text='The author of this message.', on_delete=django.db.models.deletion.CASCADE, to='api.User')), ('deletion_context', models.ForeignKey(help_text='The deletion context this message is part of.', on_delete=django.db.models.deletion.CASCADE, to='api.MessageDeletionContext')), ], diff --git a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py index 124c6a57..622f21d1 100644 --- a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py +++ b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py @@ -3,7 +3,6 @@ import django.contrib.postgres.fields import django.contrib.postgres.fields.jsonb from django.db import migrations -import pydis_site.apps.api.models.utils class Migration(migrations.Migration): @@ -16,6 +15,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='deletedmessage', name='embeds', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), blank=True, help_text='Embeds attached to this message.', size=None), + field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[]), blank=True, help_text='Embeds attached to this message.', size=None), ), ] diff --git a/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py b/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py index 9e8f2fb9..95ef5850 100644 --- a/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py +++ b/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py @@ -2,7 +2,6 @@ import django.contrib.postgres.fields from django.db import migrations, models -import pydis_site.apps.api.models.utils class Migration(migrations.Migration): @@ -20,6 +19,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='deletedmessage', name='embeds', - field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), blank=True, help_text='Embeds attached to this message.', size=None), + field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(validators=[]), blank=True, help_text='Embeds attached to this message.', size=None), ), ] -- cgit v1.2.3 From b6ac1f2f7c402411a29f1cfee10d79abb7c001d0 Mon Sep 17 00:00:00 2001 From: Ash <92868529+Ash-02014@users.noreply.github.com> Date: Fri, 1 Jul 2022 15:59:16 +0100 Subject: Update pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md index e6095252..431ab095 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md @@ -102,7 +102,7 @@ __**EXPLANATION**__ - `import discord` imports the **discord.py** package. - `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid. -- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commaands. +- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commands, and binds it to the `discord.Client` subclass instance, so wherever you have access to it, you will also have access to the command tree. - Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. - Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`. - Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker. -- cgit v1.2.3 From b237e81890836343295bd84b80ff5b34ef05ab92 Mon Sep 17 00:00:00 2001 From: Ash <92868529+Ash-02014@users.noreply.github.com> Date: Fri, 1 Jul 2022 15:59:26 +0100 Subject: Update pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com> --- .../resources/guides/python-guides/discord-app-commands.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md index 431ab095..1a6e2453 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md @@ -6,7 +6,15 @@ description: A simple guide to creating slash commands within discord.py! --- -Upon resumption of the most popular discord API wrapper library for python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with addition of features to the library. Some additions to the library are -> Buttons support, Select Menus Support, Forms (AKA Modals), Slash Commands (AKA Application Commands) and a bunch of more handy features! All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py gist regarding resumption can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6). +Upon resumption of the most popular discord API wrapper library for python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with addition of features to the library. Some additions to the library include: + +- Buttons support +- Select Menus support +- Forms (AKA Modals) +- Slash commands (AKA Application Commands) +...and a bunch more handy features! + +All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py gist regarding resumption can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6). # Why this gist? -- cgit v1.2.3 From 823a6d2e3ebe7667b6bb2e2cc49635b4684cefcc Mon Sep 17 00:00:00 2001 From: Ash <92868529+Ash-02014@users.noreply.github.com> Date: Fri, 1 Jul 2022 16:00:51 +0100 Subject: Update pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md A good point indeed Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md index 1a6e2453..d5f204b0 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md @@ -111,7 +111,7 @@ __**EXPLANATION**__ - `import discord` imports the **discord.py** package. - `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid. - Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commands, and binds it to the `discord.Client` subclass instance, so wherever you have access to it, you will also have access to the command tree. -- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. +- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. **Without calling this method, your changes will only be saved locally and will NOT show up on Discord!** - Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`. - Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker. - And the classic old `client.run("token")` is used to connect the client to the discord gateway. -- cgit v1.2.3 From 2a8b2f4b764c83584972224ad2cf8c625e6712e1 Mon Sep 17 00:00:00 2001 From: Ash <92868529+Ash-02014@users.noreply.github.com> Date: Fri, 1 Jul 2022 16:01:33 +0100 Subject: Update pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md index d5f204b0..7d7239e2 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md @@ -133,7 +133,7 @@ class SlashBot(commands.Bot): bot = SlashBot() @bot.tree.command(name="ping", description="...") -async def _ping(interaction: discord.Interaction) -> None: +async def ping(interaction: discord.Interaction) -> None: await interaction.response.send_message("pong") bot.run("token") -- cgit v1.2.3 From 7e5fe0bab6e2b97d2fc4e855d05573ca02801caf Mon Sep 17 00:00:00 2001 From: Ash <92868529+Ash-02014@users.noreply.github.com> Date: Fri, 1 Jul 2022 16:03:16 +0100 Subject: Update pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md index 7d7239e2..1f5a0ff3 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md @@ -166,7 +166,7 @@ class MySlashCog(commands.Cog): self.bot = bot @app_commands.command(name="ping", description="...") - async def _ping(self, interaction: discord.Interaction): + async def ping(self, interaction: discord.Interaction): await interaction.response.send_message("pong!") class MySlashBot(commands.Bot): -- cgit v1.2.3 From fff37e8bfd0f3ace81235a00b0d343cd8ba21383 Mon Sep 17 00:00:00 2001 From: Ash <92868529+Ash-02014@users.noreply.github.com> Date: Fri, 1 Jul 2022 16:03:36 +0100 Subject: Update pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md quite alright Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md index 1f5a0ff3..65c0b025 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md @@ -410,7 +410,7 @@ async def on_tree_error(interaction: discord.Interaction, error: app_commands.Ap if isinstance(error, app_commands.CommandOnCooldown): return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!") - elif isinstance(..., ...): + elif isinstance(error, ...): ... else: -- cgit v1.2.3 From ca3cb63131839258cdec5d28317a90193a15f082 Mon Sep 17 00:00:00 2001 From: Ash <92868529+Ash-02014@users.noreply.github.com> Date: Fri, 1 Jul 2022 16:03:57 +0100 Subject: Update pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md index 65c0b025..02a56ba6 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md @@ -436,7 +436,7 @@ bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) #sync commands @bot.tree.command(name="ping") -app_commands.checks.cooldown(1, 30) +@app_commands.checks.cooldown(1, 30) async def ping(interaction: discord.Interaction): await interaction.response.send_message("pong!") -- cgit v1.2.3 From 9344c927c4e4deba148202f23b7803c775d698a6 Mon Sep 17 00:00:00 2001 From: Ash <92868529+Ash-02014@users.noreply.github.com> Date: Fri, 1 Jul 2022 16:05:07 +0100 Subject: Update pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/discord-app-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md index 02a56ba6..917c63a1 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md @@ -445,7 +445,7 @@ async def ping_error(interaction: discord.Interaction, error: app_commands.AppCo if isinstance(error, app_commands.CommandOnCooldown): return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!") - elif isinstance(..., ...): + elif isinstance(error, ...): ... else: -- cgit v1.2.3 From 7475676fddb5b815e8ff24de3b5d094cda66439e Mon Sep 17 00:00:00 2001 From: Blue-Puddle Date: Fri, 1 Jul 2022 16:15:16 +0100 Subject: add app_commands.md --- .../resources/guides/python-guides/app_commands.md | 448 ++++++++++++++++++++ .../guides/python-guides/discord-app-commands.md | 459 --------------------- 2 files changed, 448 insertions(+), 459 deletions(-) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/app_commands.md delete mode 100644 pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/app_commands.md b/pydis_site/apps/content/resources/guides/python-guides/app_commands.md new file mode 100644 index 00000000..d97b849a --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/app_commands.md @@ -0,0 +1,448 @@ +# DISCORD.PY RESUMATION CHANGES + +--- + +Upon resumation of the most popular discord API wrapper library for python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with addition of features to the library. Some additions to the library are -> Buttons support, Select Menus Support, Forms (AKA Modals), Slash Commands (AKA Application Commands) and a bunch of more handy features! All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py gist regarding resumation can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6). + +# Why this gist? + +--- + +This Gist is being created as an update to slash commands (app commands) with explanation and examples. This Gist mainly focuses on **SLASH COMMANDS** for discord.py 2.0 (and above)! + +# What Are Slash Commands? + +--- + +Slash Commands are the exciting way to build and interact with bots on Discord. With Slash Commands, all you have to do is type / and you're ready to use your favourite bot. You can easily see all the commands a bot has, and validation and error handling help you get the command right the first time. + +# Install the latest version for discord.py + +--- +To use slash commands with discord.py. The latest up-to-date version has to be installed. Make sure that the version is 2.0 or above! +And make sure to uninstall any third party libraries that support slash commands for discord.py (if any) as a few of them [monkey patch](https://en.wikipedia.org/wiki/Monkey_patch#:~:text=A%20monkey%20patch%20is%20a,running%20instance%20of%20the%20program) discord.py! + +The latest and up-to-date usable discord.py version can be installed using `pip install -U git+https://github.com/Rapptz/discord.py`. + +If you get an error such as: `'git' is not recognized...`. [Install git](https://git-scm.com/downloads) for your platform. Go through the required steps for installing git and make sure to enable the `add to path` option while in the installation wizard. After installing git you can run `pip install -U git+https://github.com/Rapptz/discord.py` again to install discord.py 2.0. +**BEFORE MIGRATING TO DISCORD.PY 2.0, PLEASE READ THE CONSEQUENCES OF THE UPDATE [HERE](https://discordpy.readthedocs.io/en/latest/migrating.html)**. + +# Basic Structure for Discord.py Slash Commands! + +--- + +### Note that Slash Commands in discord.py are also referred to as **Application Commmands** and **App Commands** and every *interaction* is a *webhook*. +Slash commands in discord.py are held by a container, [CommandTree](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=commandtree#discord.app_commands.CommandTree). A command tree is required to create slash commands in discord.py. This command tree provides a `command` method which decorates an asynchronous function indicating to discord.py that the decorated function is intended to be a slash command. This asynchronous function expects a default argument which acts as the interaction which took place that invoked the slash command. This default argument is an instance of the **Interaction** class from discord.py. Further up, the command logic takes over the behaviour of the slash command. + +# Fundamentals for this gist! + +--- + + +The fundamental for this gist will remain to be the `setup_hook`. The `setup_hook` is a special asynchronous method of the Client and Bot class which can be overwritten to perform numerous tasks. This method is safe to use as it is always triggered before any events are dispatched, i.e. this method is triggered before the *IDENTIFY* payload is sent to the discord gateway. +Note that methods of the Bot class such as `change_presence` will not work in setup_hook as the current application does not have an active connection to the gateway at this point. + +__**FOLLOWING IS THE EXAMPLE OF HOW A `SETUP_HOOK` FUNCTION CAN BE DEFINED**__ + +```python +import discord + +'''This is one way of creating a "setup_hook" method''' + +class SlashClient(discord.Client): + def __init__(self) -> None: + super().__init__(intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + #perform tasks + +'''Another way of creating a "setup_hook" is as follows''' + +client = discord.Client(intents=discord.Intents.default()) +async def my_setup_hook() -> None: + #perform tasks + +client.setup_hook = my_setup_hook +``` + +# Basic Slash Command application using discord.py. + +#### The `CommandTree` class resides within the `app_commands` of discord.py package. +--- + +## Slash Command Application with a Client + +```python +import discord + +class SlashClient(discord.Client): + def __init__(self) -> None: + super().__init__(intents=discord.Intents.default()) + self.tree = discord.app_commands.CommandTree(self) + + async def setup_hook(self) -> None: + self.tree.copy_global_to(guild=discord.Object(id=12345678900987654)) + await self.tree.sync() + +client = SlashClient() + +@client.tree.command(name="ping", description="...") +async def _ping(interaction: discord.Interaction) -> None: + await interaction.response.send_message("pong") + +client.run("token") +``` + + +__**EXPLANATION**__ + +- `import discord` imports the **discord.py** package. +- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid. +- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commaands. +- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. +- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`. +- Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker. +- And the classic old `client.run("token")` is used to connect the client to the discord gateway. +- Note that the `send_message` is a method of the `InteractionResponse` class and `interaction.response` in this case is an instance of the `InteractionResponse` object. The `send_message` method will not function if the response is not sent within 3 seconds of command invocation. I will discuss about how to handle this issue later following the gist. + +## Slash Command Application with a Bot + +```python +import discord + +class SlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix=".", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + self.tree.copy_global_to(guild=discord.Object(id=12345678900987654)) + await self.tree.sync() + +bot = SlashBot() + +@bot.tree.command(name="ping", description="...") +async def _ping(interaction: discord.Interaction) -> None: + await interaction.response.send_message("pong") + +bot.run("token") +``` + +The above example shows a basic slash commands within discord.py using the Bot class. + +__**EXPLANATION**__ + +Most of the explanation is the same as the prior example which featured `SlashClient` which was a subclass of **discord.Client**. Though some minor changes are discussed below. + +- The `SlashBot` class now subclasses `discord.ext.commands.Bot` following the passing in of the required arguments to its `__init__` method. +- `discord.ext.commands.Bot` already consists of an instance of the `CommandTree` class which can be accessed using the `tree` property. + +# Slash Commands within a Cog! + +--- + +A cog is a collection of commands, listeners, and optional state to help group commands together. More information on them can be found on the [Cogs](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs) page. + +## An Example to using cogs with discord.py for slash commands! + +```python +import discord +from discord.ext import commands +from discord import app_commands + +class MySlashCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @app_commands.command(name="ping", description="...") + async def _ping(self, interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +class MySlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix="!", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + await self.add_cog(MySlashCog(self)) + await self.tree.copy_global_to(discord.Object(id=123456789098765432)) + await self.tree.sync() + +bot = MySlashBot() + +bot.run("token") +``` + +__**EXPLANATION**__ + +- Firstly, `import discord` imports the **discord.py** package. `from discord import app_commands` imports the `app_commands` directory from the **discord.py** root directory. `from discord.ext import commands` imports the commands extension. +- Further up, `class MySlashCog(commands.Cog)` is a class subclassing the `Cog` class. You can read more about this [here](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs). +- `def __init__(self, bot: commands.Bot): self.bot = bot` is the constructor method of the class that is always run when the class is instantiated and that is why we pass in a **Bot** object whenever we create an instance of the cog class. +- Following up is the `@app_commands.command(name="ping", description="...")` decorator. This decorator basically functions the same as a `bot.tree.command` but since the cog currently does not have a **bot**, the `app_commands.command` decorator is used instead. The next two lines follow the same structure for slash commands with **self** added as the first parameter to the function as it is a method of a class. +- The next up lines are mostly the same. +- Talking about the first line inside the `setup_hook` is the `add_cog` method of the **Bot** class. And since **self** acts as the **instance** of the current class, we use **self** to use the `add_cog` method of the **Bot** class as we are inside a subclassed class of the **Bot** class. Then we pass in **self** to the `add_cog` method as the `__init__` function of the **MySlashCog** cog accepts a `Bot` object. +- After that we instantiate the `MySlashBot` class and run the bot using the **run** method which executes our setup_hook function and our commands get loaded and synced. The bot is now ready to use! + +# An Example to using groups with discord.py for slash commands! + +--- + +## An example with optional group! + +```python +import discord +from discord.ext import commands +from discord import app_commands + +class MySlashGroupCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + #-------------------------------------------------------- + group = app_commands.Group(name="uwu", description="...") + #-------------------------------------------------------- + + @app_commands.command(name="ping", description="...") + async def _ping(self, interaction: discord.) -> None: + await interaction.response.send_message("pong!") + + @group.command(name="command", description="...") + async def _cmd(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message("uwu") + +class MySlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix="!", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + await self.add_cog(MySlashGroupCog(self)) + await self.tree.copy_global_to(discord.Object(id=123456789098765432)) + await self.tree.sync() + +bot = MySlashBot() + +bot.run("token") +``` + +__**EXPLANATION**__ +- The only difference used here is `group = app_commands.Group(name="uwu", description="...")` and `group.command`. `app_commands.Group` is used to initiate a group while `group.command` registers a command under a group. For example, the ping command can be run using **/ping** but this is not the case for group commands. They are registered with the format of `group_name command_name`. So here, the **command** command of the **uwu** group would be run using **/uwu command**. Note that only group commands can have a single space between them. + +--- + +## An example with a **Group** subclass! + +```python +import discord +from discord.ext import commands +from discord import app_commands + +class MySlashGroup(app_commands.Group, name="uwu"): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + super().__init__() + + @app_commands.command(name="ping", description="...") + async def _ping(self, interaction: discord.) -> None: + await interaction.response.send_message("pong!") + + @app_commands.command(name="command", description="...") + async def _cmd(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message("uwu") + +class MySlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix="!", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + await self.add_cog(MySlashGroup(self)) + await self.tree.copy_global_to(discord.Object(id=123456789098765432)) + await self.tree.sync() + +bot = MySlashBot() + +bot.run("token") +``` + +__**EXPLANATION**__ +- The only difference here too is that the `MySlashGroup` class directly subclasses the **Group** class from discord.app_commands which automatically registers all the methods within the group class to be commands of that specific group. So now, the commands such as `ping` can be run using **/uwu ping** and `command` using **/uwu command**. + + +# Some common methods and features used for slash commands. + +--- + +### A common function used for slash commands is the `describe` function. This is used to add descriptions to the arguments of a slash command. The command function can decorated with this function. It goes by the following syntax as shown below. + +```python +from discord.ext import commands +from discord import app_commands +import discord + +bot = commands.Bot(command_prefix=".", intents=discord.Intents.default()) +#sync the commands + +@bot.tree.command(name="echo", description="...") +@app_commands.describe(text="The text to send!", channel="The channel to send the message in!") +async def _echo(interaction: discord.Interaction, text: str, channel: discord.TextChannel=None): + channel = interaction.channel or channel + await channel.send(text) +``` + +### Another common issue that most people come across is the time duraction of sending a message with `send_message`. This issue can be tackled by deferring the interaction response using the `defer` method of the `InteractionResponse` class. An example for fixing this issue is shown below. + +```python +import discord +from discord.ext import commands +import asyncio + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync the commands + +@bot.tree.command(name="time", description="...") +async def _time(interaction: discord.Interaction, time_to_wait: int): + # ------------------------------------------------------------- + await interaction.response.defer(ephemeral=True, thinking=True) + # ------------------------------------------------------------- + await interaction.edit_original_message(content=f"I will notify you after {time_to_wait} seconds have passed!") + await asyncio.sleep(time_to_wait) + await interaction.edit_original_message(content=f"{interaction.user.mention}, {time_to_wait} seconds have already passed!") +``` + +# Checking for Permissions and Roles! + +--- + +To add a permissions check to a command, the methods are imported through `discord.app_commands.checks`. To check for a member's permissions, the function can be decorated with the [discord.app_commands.checks.has_permissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=has_permissions#discord.app_commands.checks.has_permissions) method. An example to this as follows. + +```py +from discord import app_commands +from discord.ext import commands +import discord + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync commands + +@bot.tree.command(name="ping") +@app_commands.checks.has_permissions(manage_messages=True, manage_channels=True) #example permissions +async def _ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +``` + +If the check fails, it will raise a `MissingPermissions` error which can be handled within an app commands error handler! I will discuss about making an error handler later in the gist. All the permissions can be found [here](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discord%20permissions#discord.Permissions). + +Other methods that you can decorate the commands with are - +- `bot_has_permissions` | This checks if the bot has the required permissions for executing the slash command. This raises a [BotMissingPermissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.BotMissingPermissions) exception. +- `has_role` | This checks if the slash command user has the required role or not. Only **ONE** role name or role ID can be passed to this. If the name is being passed, make sure to have the exact same name as the role name. This raises a [MissingRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingRole) exception. +- To pass in several role names or role IDs, `has_any_role` can be used to decorate a command. This raises two exceptions -> [MissingAnyRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingAnyRole) and [NoPrivateMessage](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.NoPrivateMessage) + + +# Adding cooldowns to slash commands! + +--- + +Slash Commands within discord.py can be applied cooldowns to in order to prevent spamming of the commands. This can be done through the `discord.app_commands.checks.cooldown` method which can be used to decorate a slash command function and register a cooldown to the function. This raises a [CommandOnCooldown](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=checks%20cooldown#discord.app_commands.CommandOnCooldown) exception if the command is currently on cooldown. +An example is as follows. + +```python +from discord.ext import commands +import discord + +class Bot(commands.Bot): + def __init__(self): + super().__init__(command_prefix="uwu", intents=discord.Intents.all()) + + + async def setup_hook(self): + self.tree.copy_global_to(guild=discord.Object(id=12345678909876543)) + await self.tree.sync() + + +bot = Bot() + +@bot.tree.command(name="ping") +# ----------------------------------------- +@discord.app_commands.checks.cooldown(1, 30) +# ----------------------------------------- +async def ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +bot.run("token") +``` + +__**EXPLANATION**__ +- The first argument the `cooldown` method takes is the amount of times the command can be run in a specific period of time. +- The second argument it takes is the period of time in which the command can be run the specified number of times. +- The `CommandOnCooldown` exception can be handled using an error handler. I will discuss about making an error handler for slash commands later in the gist. + + +# Handling errors for slash commands! + +--- + +The Slash Commands exceptions can be handled by overwriting the `on_error` method of the `CommandTree`. The error handler takes two arguments. The first argument is the `Interaction` that took place when the error occurred and the second argument is the error that occurred when the slash commands was invoked. The error is an instance of [discord.app_commands.AppCommandError](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=appcommanderror#discord.app_commands.AppCommandError) which is a subclass of [DiscordException](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discordexception#discord.DiscordException). +An example to creating an error handler for slash commands is as follows. + +```python +from discord.ext import commands +from discord import app_commands +import discord + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync commands + +@bot.tree.command(name="ping") +@app_commands.checks.cooldown(1, 30) +async def ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +async def on_tree_error(interaction: discord.Interaction, error: app_commands.AppCommandError): + if isinstance(error, app_commands.CommandOnCooldown): + return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!") + + elif isinstance(..., ...): + ... + + else: + raise error + +bot.tree.on_error = on_tree_error + +bot.run("token") +``` + +__**EXPLANATION**__ + +First we create a simple function named as `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here I have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. The `raise error` is just for displayed unhandled errors, i.e. the ones which have not been specificed manually. If this is **removed**, you will not be able to see any exceptions raised due to slash commands and makes debugging the code harder. +After creating the error handler function, we set the function as the error handler for the slash commands. Here, `bot.tree.on_error = on_tree_error` overwrites the default `on_error` method of the **CommandTree** class with our custom error handler which has been named as `on_tree_error` here. + +### Creating an error handler for a specific error! + +```python +from discord.ext import commands +from discord import app_commands +import discord + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync commands + +@bot.tree.command(name="ping") +app_commands.checks.cooldown(1, 30) +async def ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +@ping.error +async def ping_error(interaction: discord.Interaction, error: app_commands.AppCommandError): + if isinstance(error, app_commands.CommandOnCooldown): + return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!") + + elif isinstance(error, ...): + ... + + else: + raise error + +bot.run("token") +``` + +__**EXPLANATION**__ + +Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. Please do not call the `error` method. \ No newline at end of file diff --git a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md deleted file mode 100644 index 917c63a1..00000000 --- a/pydis_site/apps/content/resources/guides/python-guides/discord-app-commands.md +++ /dev/null @@ -1,459 +0,0 @@ ---- -title: Slash Commands with discord.py! -description: A simple guide to creating slash commands within discord.py! ---- -# DISCORD.PY RESUMPTION CHANGES - ---- - -Upon resumption of the most popular discord API wrapper library for python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with addition of features to the library. Some additions to the library include: - -- Buttons support -- Select Menus support -- Forms (AKA Modals) -- Slash commands (AKA Application Commands) -...and a bunch more handy features! - -All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py gist regarding resumption can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6). - -# Why this gist? - ---- - -This Gist is being created as an update to slash commands (app commands) with explanation and examples. This Gist mainly focuses on **SLASH COMMANDS** for discord.py 2.0 (and above)! - -# What Are Slash Commands? - ---- - -Slash Commands are the exciting way to build and interact with bots on Discord. With Slash Commands, all you have to do is type / and you're ready to use your favourite bot. You can easily see all the commands a bot has, and validation and error handling help you get the command right the first time. - -# Install the latest version for discord.py - ---- -To use slash commands with discord.py. The latest up-to-date version has to be installed. Make sure that the version is 2.0 or above! -And make sure to uninstall any third party libraries that support slash commands for discord.py (if any) as a few of them [monkey patch](https://en.wikipedia.org/wiki/Monkey_patch#:~:text=A%20monkey%20patch%20is%20a,running%20instance%20of%20the%20program) discord.py! - -The latest and up-to-date usable discord.py version can be installed using `pip install -U git+https://github.com/Rapptz/discord.py`. - -If you get an error such as: `'git' is not recognized...`. [Install git](https://git-scm.com/downloads) for your platform. Go through the required steps for installing git and make sure to enable the `add to path` option while in the installation wizard. After installing git you can run `pip install -U git+https://github.com/Rapptz/discord.py` again to install discord.py 2.0. -**BEFORE MIGRATING TO DISCORD.PY 2.0, PLEASE READ THE CONSEQUENCES OF THE UPDATE [HERE](https://discordpy.readthedocs.io/en/latest/migrating.html)**. - -# Basic Structure for Discord.py Slash Commands! - ---- - -### Note that Slash Commands in discord.py are also referred to as **Application Commmands** and **App Commands** and every *interaction* is a *webhook*. -Slash commands in discord.py are held by a container, [CommandTree](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=commandtree#discord.app_commands.CommandTree). A command tree is required to create slash commands in discord.py. This command tree provides a `command` method which decorates an asynchronous function indicating to discord.py that the decorated function is intended to be a slash command. This asynchronous function expects a default argument which acts as the interaction which took place that invoked the slash command. This default argument is an instance of the **Interaction** class from discord.py. Further up, the command logic takes over the behaviour of the slash command. - -# Fundamentals for this gist! - ---- - - -The fundamental for this gist will remain to be the `setup_hook`. The `setup_hook` is a special asynchronous method of the Client and Bot class which can be overwritten to perform numerous tasks. This method is safe to use as it is always triggered before any events are dispatched, i.e. this method is triggered before the *IDENTIFY* payload is sent to the discord gateway. -Note that methods of the Bot class such as `change_presence` will not work in setup_hook as the current application does not have an active connection to the gateway at this point. - -__**FOLLOWING IS THE EXAMPLE OF HOW A `SETUP_HOOK` FUNCTION CAN BE DEFINED**__ - -```python -import discord - -'''This is one way of creating a "setup_hook" method''' - -class SlashClient(discord.Client): - def __init__(self) -> None: - super().__init__(intents=discord.Intents.default()) - - async def setup_hook(self) -> None: - #perform tasks - -'''Another way of creating a "setup_hook" is as follows''' - -client = discord.Client(intents=discord.Intents.default()) -async def my_setup_hook() -> None: - #perform tasks - -client.setup_hook = my_setup_hook -``` - -# Basic Slash Command application using discord.py. - -#### The `CommandTree` class resides within the `app_commands` of discord.py package. ---- - -## Slash Command Application with a Client - -```python -import discord - -class SlashClient(discord.Client): - def __init__(self) -> None: - super().__init__(intents=discord.Intents.default()) - self.tree = discord.app_commands.CommandTree(self) - - async def setup_hook(self) -> None: - self.tree.copy_global_to(guild=discord.Object(id=12345678900987654)) - await self.tree.sync() - -client = SlashClient() - -@client.tree.command(name="ping", description="...") -async def _ping(interaction: discord.Interaction) -> None: - await interaction.response.send_message("pong") - -client.run("token") -``` - - -__**EXPLANATION**__ - -- `import discord` imports the **discord.py** package. -- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid. -- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commands, and binds it to the `discord.Client` subclass instance, so wherever you have access to it, you will also have access to the command tree. -- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. Further up, `self.tree.sync()` updates the API with any changes to the slash commands. **Without calling this method, your changes will only be saved locally and will NOT show up on Discord!** -- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`. -- Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker. -- And the classic old `client.run("token")` is used to connect the client to the discord gateway. -- Note that the `send_message` is a method of the `InteractionResponse` class and `interaction.response` in this case is an instance of the `InteractionResponse` object. The `send_message` method will not function if the response is not sent within 3 seconds of command invocation. I will discuss about how to handle this issue later following the gist. - -## Slash Command Application with a Bot - -```python -import discord - -class SlashBot(commands.Bot): - def __init__(self) -> None: - super().__init__(command_prefix=".", intents=discord.Intents.default()) - - async def setup_hook(self) -> None: - self.tree.copy_global_to(guild=discord.Object(id=12345678900987654)) - await self.tree.sync() - -bot = SlashBot() - -@bot.tree.command(name="ping", description="...") -async def ping(interaction: discord.Interaction) -> None: - await interaction.response.send_message("pong") - -bot.run("token") -``` - -The above example shows a basic slash commands within discord.py using the Bot class. - -__**EXPLANATION**__ - -Most of the explanation is the same as the prior example which featured `SlashClient` which was a subclass of **discord.Client**. Though some minor changes are discussed below. - -- The `SlashBot` class now subclasses `discord.ext.commands.Bot` following the passing in of the required arguments to its `__init__` method. -- `discord.ext.commands.Bot` already consists of an instance of the `CommandTree` class which can be accessed using the `tree` property. - -# Slash Commands within a Cog! - ---- - -A cog is a collection of commands, listeners, and optional state to help group commands together. More information on them can be found on the [Cogs](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs) page. - -## An Example to using cogs with discord.py for slash commands! - -```python -import discord -from discord.ext import commands -from discord import app_commands - -class MySlashCog(commands.Cog): - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - - @app_commands.command(name="ping", description="...") - async def ping(self, interaction: discord.Interaction): - await interaction.response.send_message("pong!") - -class MySlashBot(commands.Bot): - def __init__(self) -> None: - super().__init__(command_prefix="!", intents=discord.Intents.default()) - - async def setup_hook(self) -> None: - await self.add_cog(MySlashCog(self)) - await self.tree.copy_global_to(discord.Object(id=123456789098765432)) - await self.tree.sync() - -bot = MySlashBot() - -bot.run("token") -``` - -__**EXPLANATION**__ - -- Firstly, `import discord` imports the **discord.py** package. `from discord import app_commands` imports the `app_commands` directory from the **discord.py** root directory. `from discord.ext import commands` imports the commands extension. -- Further up, `class MySlashCog(commands.Cog)` is a class subclassing the `Cog` class. You can read more about this [here](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs). -- `def __init__(self, bot: commands.Bot): self.bot = bot` is the constructor method of the class that is always run when the class is instantiated and that is why we pass in a **Bot** object whenever we create an instance of the cog class. -- Following up is the `@app_commands.command(name="ping", description="...")` decorator. This decorator basically functions the same as a `bot.tree.command` but since the cog currently does not have a **bot**, the `app_commands.command` decorator is used instead. The next two lines follow the same structure for slash commands with **self** added as the first parameter to the function as it is a method of a class. -- The next up lines are mostly the same. -- Talking about the first line inside the `setup_hook` is the `add_cog` method of the **Bot** class. And since **self** acts as the **instance** of the current class, we use **self** to use the `add_cog` method of the **Bot** class as we are inside a subclassed class of the **Bot** class. Then we pass in **self** to the `add_cog` method as the `__init__` function of the **MySlashCog** cog accepts a `Bot` object. -- After that we instantiate the `MySlashBot` class and run the bot using the **run** method which executes our setup_hook function and our commands get loaded and synced. The bot is now ready to use! - -# An Example to using groups with discord.py for slash commands! - ---- - -## An example with optional group! - -```python -import discord -from discord.ext import commands -from discord import app_commands - -class MySlashGroupCog(commands.Cog): - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - - #-------------------------------------------------------- - group = app_commands.Group(name="uwu", description="...") - #-------------------------------------------------------- - - @app_commands.command(name="ping", description="...") - async def _ping(self, interaction: discord.) -> None: - await interaction.response.send_message("pong!") - - @group.command(name="command", description="...") - async def _cmd(self, interaction: discord.Interaction) -> None: - await interaction.response.send_message("uwu") - -class MySlashBot(commands.Bot): - def __init__(self) -> None: - super().__init__(command_prefix="!", intents=discord.Intents.default()) - - async def setup_hook(self) -> None: - await self.add_cog(MySlashGroupCog(self)) - await self.tree.copy_global_to(discord.Object(id=123456789098765432)) - await self.tree.sync() - -bot = MySlashBot() - -bot.run("token") -``` - -__**EXPLANATION**__ -- The only difference used here is `group = app_commands.Group(name="uwu", description="...")` and `group.command`. `app_commands.Group` is used to initiate a group while `group.command` registers a command under a group. For example, the ping command can be run using **/ping** but this is not the case for group commands. They are registered with the format of `group_name command_name`. So here, the **command** command of the **uwu** group would be run using **/uwu command**. Note that only group commands can have a single space between them. - ---- - -## An example with a **Group** subclass! - -```python -import discord -from discord.ext import commands -from discord import app_commands - -class MySlashGroup(commands.GroupCog, name="uwu"): - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - super().__init__() - - @app_commands.command(name="ping", description="...") - async def _ping(self, interaction: discord.) -> None: - await interaction.response.send_message("pong!") - - @app_commands.command(name="command", description="...") - async def _cmd(self, interaction: discord.Interaction) -> None: - await interaction.response.send_message("uwu") - -class MySlashBot(commands.Bot): - def __init__(self) -> None: - super().__init__(command_prefix="!", intents=discord.Intents.default()) - - async def setup_hook(self) -> None: - await self.add_cog(MySlashGroup(self)) - await self.tree.copy_global_to(discord.Object(id=123456789098765432)) - await self.tree.sync() - -bot = MySlashBot() - -bot.run("token") -``` - -__**EXPLANATION**__ -- The only difference here too is that the `MySlashGroup` class directly subclasses the **GroupCog** class from discord.ext.commands which automatically registers all the methods within the group class to be commands of that specific group. So now, the commands such as `ping` can be run using **/uwu ping** and `command` using **/uwu command**. - - -# Some common methods and features used for slash commands. - ---- - -### A common function used for slash commands is the `describe` function. This is used to add descriptions to the arguments of a slash command. The command function can decorated with this function. It goes by the following syntax as shown below. - -```python -from discord.ext import commands -from discord import app_commands -import discord - -bot = commands.Bot(command_prefix=".", intents=discord.Intents.default()) -#sync the commands - -@bot.tree.command(name="echo", description="...") -@app_commands.describe(text="The text to send!", channel="The channel to send the message in!") -async def _echo(interaction: discord.Interaction, text: str, channel: discord.TextChannel=None): - channel = interaction.channel or channel - await channel.send(text) -``` - -### Another common issue that most people come across is the time duraction of sending a message with `send_message`. This issue can be tackled by deferring the interaction response using the `defer` method of the `InteractionResponse` class. An example for fixing this issue is shown below. - -```python -import discord -from discord.ext import commands -import asyncio - -bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) -#sync the commands - -@bot.tree.command(name="time", description="...") -async def _time(interaction: discord.Interaction, time_to_wait: int): - # ------------------------------------------------------------- - await interaction.response.defer(ephemeral=True, thinking=True) - # ------------------------------------------------------------- - await interaction.edit_original_message(content=f"I will notify you after {time_to_wait} seconds have passed!") - await asyncio.sleep(time_to_wait) - await interaction.edit_original_message(content=f"{interaction.user.mention}, {time_to_wait} seconds have already passed!") -``` - -# Checking for Permissions and Roles! - ---- - -To add a permissions check to a command, the methods are imported through `discord.app_commands.checks`. To check for a member's permissions, the function can be decorated with the [discord.app_commands.checks.has_permissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=has_permissions#discord.app_commands.checks.has_permissions) method. An example to this as follows. - -```py -from discord import app_commands -from discord.ext import commands -import discord - -bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) -#sync commands - -@bot.tree.command(name="ping") -@app_commands.checks.has_permissions(manage_messages=True, manage_channels=True) #example permissions -async def _ping(interaction: discord.Interaction): - await interaction.response.send_message("pong!") - -``` - -If the check fails, it will raise a `MissingPermissions` error which can be handled within an app commands error handler! I will discuss about making an error handler later in the gist. All the permissions can be found [here](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discord%20permissions#discord.Permissions). - -Other methods that you can decorate the commands with are - -- `bot_has_permissions` | This checks if the bot has the required permissions for executing the slash command. This raises a [BotMissingPermissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.BotMissingPermissions) exception. -- `has_role` | This checks if the slash command user has the required role or not. Only **ONE** role name or role ID can be passed to this. If the name is being passed, make sure to have the exact same name as the role name. This raises a [MissingRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingRole) exception. -- To pass in several role names or role IDs, `has_any_role` can be used to decorate a command. This raises two exceptions -> [MissingAnyRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingAnyRole) and [NoPrivateMessage](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.NoPrivateMessage) - - -# Adding cooldowns to slash commands! - ---- - -Slash Commands within discord.py can be applied cooldowns to in order to prevent spamming of the commands. This can be done through the `discord.app_commands.checks.cooldown` method which can be used to decorate a slash command function and register a cooldown to the function. This raises a [CommandOnCooldown](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=checks%20cooldown#discord.app_commands.CommandOnCooldown) exception if the command is currently on cooldown. -An example is as follows. - -```python -from discord.ext import commands -import discord - -class Bot(commands.Bot): - def __init__(self): - super().__init__(command_prefix="uwu", intents=discord.Intents.all()) - - - async def setup_hook(self): - self.tree.copy_global_to(guild=discord.Object(id=12345678909876543)) - await self.tree.sync() - - -bot = Bot() - -@bot.tree.command(name="ping") -# ----------------------------------------- -@discord.app_commands.checks.cooldown(1, 30) -# ----------------------------------------- -async def ping(interaction: discord.Interaction): - await interaction.response.send_message("pong!") - -bot.run("token") -``` - -__**EXPLANATION**__ -- The first argument the `cooldown` method takes is the amount of times the command can be run in a specific period of time. -- The second argument it takes is the period of time in which the command can be run the specified number of times. -- The `CommandOnCooldown` exception can be handled using an error handler. I will discuss about making an error handler for slash commands later in the gist. - - -# Handling errors for slash commands! - ---- - -The Slash Commands exceptions can be handled by overwriting the `on_error` method of the `CommandTree`. The error handler takes two arguments. The first argument is the `Interaction` that took place when the error occurred and the second argument is the error that occurred when the slash commands was invoked. The error is an instance of [discord.app_commands.AppCommandError](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=appcommanderror#discord.app_commands.AppCommandError) which is a subclass of [DiscordException](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discordexception#discord.DiscordException). -An example to creating an error handler for slash commands is as follows. - -```python -from discord.ext import commands -from discord import app_commands -import discord - -bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) -#sync commands - -@bot.tree.command(name="ping") -app_commands.checks.cooldown(1, 30) -async def ping(interaction: discord.Interaction): - await interaction.response.send_message("pong!") - -async def on_tree_error(interaction: discord.Interaction, error: app_commands.AppCommandError): - if isinstance(error, app_commands.CommandOnCooldown): - return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!") - - elif isinstance(error, ...): - ... - - else: - raise error - -bot.tree.on_error = on_tree_error - -bot.run("token") -``` - -__**EXPLANATION**__ - -First we create a simple function named as `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here I have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. After creating the error handler function, we set the function as the error handler for the slash commands. Here, `bot.tree.on_error = on_tree_error` overwrites the default `on_error` method of the **CommandTree** class with our custom error handler which has been named as `on_tree_error` here. - -### Creating an error handler for a specific error! - -```python -from discord.ext import commands -from discord import app_commands -import discord - -bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) -#sync commands - -@bot.tree.command(name="ping") -@app_commands.checks.cooldown(1, 30) -async def ping(interaction: discord.Interaction): - await interaction.response.send_message("pong!") - -@ping.error -async def ping_error(interaction: discord.Interaction, error: app_commands.AppCommandError): - if isinstance(error, app_commands.CommandOnCooldown): - return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!") - - elif isinstance(error, ...): - ... - - else: - raise error - -bot.run("token") -``` - -__**EXPLANATION**__ - -Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. Please do not call the `error` method. -- cgit v1.2.3 From 589ffad6935a155df4751401f884c523c777b6c6 Mon Sep 17 00:00:00 2001 From: Blue-Puddle Date: Fri, 1 Jul 2022 16:20:33 +0100 Subject: fix app_commands.md --- pydis_site/apps/content/resources/guides/python-guides/app_commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/app_commands.md b/pydis_site/apps/content/resources/guides/python-guides/app_commands.md index d97b849a..821ac577 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/app_commands.md +++ b/pydis_site/apps/content/resources/guides/python-guides/app_commands.md @@ -411,7 +411,7 @@ bot.run("token") __**EXPLANATION**__ -First we create a simple function named as `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here I have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. The `raise error` is just for displayed unhandled errors, i.e. the ones which have not been specificed manually. If this is **removed**, you will not be able to see any exceptions raised due to slash commands and makes debugging the code harder. +First we create a simple function named as `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here I have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. The `raise error` is just for displaying unhandled errors, i.e. the ones which have not been handled manually. If this is **removed**, you will not be able to see any exceptions raised by slash commands and makes debugging the code harder. After creating the error handler function, we set the function as the error handler for the slash commands. Here, `bot.tree.on_error = on_tree_error` overwrites the default `on_error` method of the **CommandTree** class with our custom error handler which has been named as `on_tree_error` here. ### Creating an error handler for a specific error! -- cgit v1.2.3 From c7b1134cd222541a1d9e56fdc89014f9742eeeaa Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Sun, 10 Jul 2022 20:37:56 +0400 Subject: Update subclassing_bot.md --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 93d65b35..e8b5354a 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -10,7 +10,7 @@ First, a [basic article](https://www.codesdope.com/course/python-subclass-of-a- Subclassing Bot can be very beneficial as it provides you with more control and customisability of how your bot functions, also allowing you to add extra features, such as custom bot attributes or methods. For example, the default [Context](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) can be overriden to add more functionality. There are two ways to subclass `commands.Bot`, as shown below: -```py +```python class CustomBot(commands.Bot): def __init__(self): super().__init__( @@ -35,7 +35,7 @@ token = YOUR_TOKEN_HERE bot.run(token) ``` Or -```py +```python class CustomBot(commands.Bot): def __init__(self, *args, **kwargs): # the key-word arguments are not specified here, unlike the example above -- cgit v1.2.3 From 563a632600bac29a45f4ae5153ae0bd9461f4561 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Sun, 10 Jul 2022 20:38:16 +0400 Subject: Update pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md Co-authored-by: Bluenix --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index e8b5354a..fc1cdb7d 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -1,6 +1,6 @@ --- title: Subclassing Bot -description: "Subclassing the Bot to add more functionality and customisability." +description: "Subclassing the discord.py Bot class to add more functionality and customisability." --- ## Basic Subclassing -- cgit v1.2.3 From f922b76b570062bcef4ec3e9ceffcefe0d60a0d6 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Sun, 10 Jul 2022 20:38:45 +0400 Subject: Update pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md Co-authored-by: Bluenix --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index fc1cdb7d..91df2199 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -4,7 +4,7 @@ description: "Subclassing the discord.py Bot class to add more functionality and --- ## Basic Subclassing -First, a [basic article](https://www.codesdope.com/course/python-subclass-of-a-class/) on subclassing will provide some fundamental knowledge, which is highly suggested before moving on to this topic, as subclassing [Bot](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot) can ultimately be a complicated task. +First, a [basic article](https://www.codesdope.com/course/python-subclass-of-a-class/) on subclassing will provide some fundamental knowledge, which is highly suggested before moving on to this topic, as subclassing [`Bot`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot) can ultimately be a complicated task. ## The benefits of subclassing bot Subclassing Bot can be very beneficial as it provides you with more control and customisability of how your bot functions, also allowing you to add extra features, such as custom bot attributes or methods. For example, the default [Context](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) can be overriden to add more functionality. -- cgit v1.2.3 From 1832a86afb5e9a13806bd759d22eb8a8eb8c7467 Mon Sep 17 00:00:00 2001 From: mina <75038675+minalike@users.noreply.github.com> Date: Sun, 10 Jul 2022 19:15:25 -0400 Subject: Fix linting --- pydis_site/apps/resources/resources/the_algorithms_github.yaml | 1 - 1 file changed, 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/the_algorithms_github.yaml b/pydis_site/apps/resources/resources/the_algorithms_github.yaml index 10052fd3..30a0a5da 100644 --- a/pydis_site/apps/resources/resources/the_algorithms_github.yaml +++ b/pydis_site/apps/resources/resources/the_algorithms_github.yaml @@ -15,4 +15,3 @@ tags: - intermediate type: - tutorial - -- cgit v1.2.3 From 586e7e9de0f483dc4adb650fb1411a5b731d023b Mon Sep 17 00:00:00 2001 From: mina <75038675+minalike@users.noreply.github.com> Date: Sun, 10 Jul 2022 19:30:25 -0400 Subject: Use font awesome lock icon that is available under free license --- pydis_site/apps/resources/templatetags/get_category_icon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/templatetags/get_category_icon.py b/pydis_site/apps/resources/templatetags/get_category_icon.py index 97b5e0a7..30bc4eaa 100644 --- a/pydis_site/apps/resources/templatetags/get_category_icon.py +++ b/pydis_site/apps/resources/templatetags/get_category_icon.py @@ -21,7 +21,7 @@ _ICONS = { "Paid": "fa-dollar-sign", "Podcast": "fa-microphone-alt", "Project Ideas": "fa-lightbulb-o", - "Security": "lock-alt", + "Security": "fa-solid fa-lock", "Software Design": "fa-paint-brush", "Subscription": "fa-credit-card", "Testing": "fa-vial", -- cgit v1.2.3 From c24ccccde65cd8d5601ade63e47f2167ba64b5ee Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 9 Jul 2022 22:27:11 +0400 Subject: Switch Out requests For httpx The requests library has been replaced by httpx. It's a drop-in replacement, but provides a better interface for certain things, such as client sessions, and sync/async support. Signed-off-by: Hassan Abouelela --- poetry.lock | 120 +++++++++++++++++++-- .../apps/home/tests/test_repodata_helpers.py | 12 +-- pydis_site/apps/home/views/home.py | 6 +- pyproject.toml | 2 +- 4 files changed, 120 insertions(+), 20 deletions(-) (limited to 'pydis_site/apps') diff --git a/poetry.lock b/poetry.lock index 3b26c275..fcdc9d84 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,20 @@ +[[package]] +name = "anyio" +version = "3.6.1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] +test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +trio = ["trio (>=0.16)"] + [[package]] name = "asgiref" version = "3.5.0" @@ -373,6 +390,52 @@ gevent = ["gevent (>=0.13)"] setproctitle = ["setproctitle"] tornado = ["tornado (>=0.2)"] +[[package]] +name = "h11" +version = "0.12.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "httpcore" +version = "0.15.0" +description = "A minimal low-level HTTP client." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +anyio = ">=3.0.0,<4.0.0" +certifi = "*" +h11 = ">=0.11,<0.13" +sniffio = ">=1.0.0,<2.0.0" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + +[[package]] +name = "httpx" +version = "0.23.0" +description = "The next generation HTTP client." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +certifi = "*" +httpcore = ">=0.15.0,<0.16.0" +rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +sniffio = "*" + +[package.extras] +brotli = ["brotlicffi", "brotli"] +cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10,<13)", "pygments (>=2.0.0,<3.0.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + [[package]] name = "identify" version = "2.4.6" @@ -617,21 +680,35 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "requests" -version = "2.27.1" +version = "2.28.1" description = "Python HTTP for Humans." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.7, <4" [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} -idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" urllib3 = ">=1.21.1,<1.27" [package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rfc3986" +version = "1.5.0" +description = "Validating URI References per RFC 3986" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} + +[package.extras] +idna2008 = ["idna"] [[package]] name = "sentry-sdk" @@ -677,6 +754,14 @@ category = "dev" optional = false python-versions = ">=3.6" +[[package]] +name = "sniffio" +version = "1.2.0" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.5" + [[package]] name = "snowballstemmer" version = "2.2.0" @@ -782,9 +867,13 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "fc9b20c33c65a289122d710844285ac20d7598e65c7f8237f8903509f5b2dea4" +content-hash = "c94949e29f868689d9c99379dbf4f9479e2ddfe5e6d49e15b57d016210a50379" [metadata.files] +anyio = [ + {file = "anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"}, + {file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"}, +] asgiref = [ {file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"}, {file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"}, @@ -959,6 +1048,12 @@ gunicorn = [ {file = "gunicorn-20.0.4-py2.py3-none-any.whl", hash = "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"}, {file = "gunicorn-20.0.4.tar.gz", hash = "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626"}, ] +h11 = [ + {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, + {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, +] +httpcore = [] +httpx = [] identify = [ {file = "identify-2.4.6-py2.py3-none-any.whl", hash = "sha256:cf06b1639e0dca0c184b1504d8b73448c99a68e004a80524c7923b95f7b6837c"}, {file = "identify-2.4.6.tar.gz", hash = "sha256:233679e3f61a02015d4293dbccf16aa0e4996f868bd114688b8c124f18826706"}, @@ -1149,9 +1244,10 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] -requests = [ - {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, - {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, +requests = [] +rfc3986 = [ + {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, + {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, ] sentry-sdk = [ {file = "sentry-sdk-0.20.3.tar.gz", hash = "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237"}, @@ -1165,6 +1261,10 @@ smmap = [ {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, ] +sniffio = [ + {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, + {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, +] snowballstemmer = [ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, diff --git a/pydis_site/apps/home/tests/test_repodata_helpers.py b/pydis_site/apps/home/tests/test_repodata_helpers.py index d43bd28e..4007eded 100644 --- a/pydis_site/apps/home/tests/test_repodata_helpers.py +++ b/pydis_site/apps/home/tests/test_repodata_helpers.py @@ -36,7 +36,7 @@ class TestRepositoryMetadataHelpers(TestCase): """Executed before each test method.""" self.home_view = HomeView() - @mock.patch('requests.get', side_effect=mocked_requests_get) + @mock.patch('httpx.get', side_effect=mocked_requests_get) def test_returns_metadata(self, _: mock.MagicMock): """Test if the _get_repo_data helper actually returns what it should.""" metadata = self.home_view._get_repo_data() @@ -59,7 +59,7 @@ class TestRepositoryMetadataHelpers(TestCase): self.assertIsInstance(metadata[0], RepositoryMetadata) self.assertIsInstance(str(metadata[0]), str) - @mock.patch('requests.get', side_effect=mocked_requests_get) + @mock.patch('httpx.get', side_effect=mocked_requests_get) def test_refresh_stale_metadata(self, _: mock.MagicMock): """Test if the _get_repo_data helper will refresh when the data is stale.""" repo_data = RepositoryMetadata( @@ -75,7 +75,7 @@ class TestRepositoryMetadataHelpers(TestCase): self.assertIsInstance(metadata[0], RepositoryMetadata) - @mock.patch('requests.get', side_effect=mocked_requests_get) + @mock.patch('httpx.get', side_effect=mocked_requests_get) def test_returns_api_data(self, _: mock.MagicMock): """Tests if the _get_api_data helper returns what it should.""" api_data = self.home_view._get_api_data() @@ -86,7 +86,7 @@ class TestRepositoryMetadataHelpers(TestCase): self.assertIn(repo, api_data.keys()) self.assertIn("stargazers_count", api_data[repo]) - @mock.patch('requests.get', side_effect=mocked_requests_get) + @mock.patch('httpx.get', side_effect=mocked_requests_get) def test_mocked_requests_get(self, mock_get: mock.MagicMock): """Tests if our mocked_requests_get is returning what it should.""" success_data = mock_get(HomeView.github_api) @@ -98,7 +98,7 @@ class TestRepositoryMetadataHelpers(TestCase): self.assertIsNotNone(success_data.json_data) self.assertIsNone(fail_data.json_data) - @mock.patch('requests.get') + @mock.patch('httpx.get') def test_falls_back_to_database_on_error(self, mock_get: mock.MagicMock): """Tests that fallback to the database is performed when we get garbage back.""" repo_data = RepositoryMetadata( @@ -117,7 +117,7 @@ class TestRepositoryMetadataHelpers(TestCase): [item] = metadata self.assertEqual(item, repo_data) - @mock.patch('requests.get') + @mock.patch('httpx.get') def test_falls_back_to_database_on_error_without_entries(self, mock_get: mock.MagicMock): """Tests that fallback to the database is performed when we get garbage back.""" mock_get.return_value.json.return_value = ['garbage'] diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py index 69e706c5..9bb1f8fd 100644 --- a/pydis_site/apps/home/views/home.py +++ b/pydis_site/apps/home/views/home.py @@ -1,7 +1,7 @@ import logging from typing import Dict, List -import requests +import httpx from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponse from django.shortcuts import render @@ -56,12 +56,12 @@ class HomeView(View): repo_dict = {} try: # Fetch the data from the GitHub API - api_data: List[dict] = requests.get( + api_data: List[dict] = httpx.get( self.github_api, headers=self.headers, timeout=settings.TIMEOUT_PERIOD ).json() - except requests.exceptions.Timeout: + except httpx.TimeoutException: log.error("Request to fetch GitHub repository metadata for timed out!") return repo_dict diff --git a/pyproject.toml b/pyproject.toml index b350836e..01e7eda2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ djangorestframework = "~=3.12.0" psycopg2-binary = "~=2.8.0" django-simple-bulma = "~=2.4" whitenoise = "~=5.0" -requests = "~=2.21" +httpx = "~=0.23.0" pyyaml = "~=5.1" gunicorn = "~=20.0.4" sentry-sdk = "~=0.19" -- cgit v1.2.3 From 7b40cd8143fea0beb195c6940bf2356970fc6958 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 11 Jul 2022 04:27:16 +0400 Subject: Drop Migration Tests The migration test suite was not really used, and it doesn't entirely make sense to test a constant unchanging process either. Its behavior is also very coupled with django's internals, and locks us into the current version and setup. Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/tests/migrations/__init__.py | 1 - pydis_site/apps/api/tests/migrations/base.py | 102 ----- .../migrations/test_active_infraction_migration.py | 496 --------------------- pydis_site/apps/api/tests/migrations/test_base.py | 135 ------ 4 files changed, 734 deletions(-) delete mode 100644 pydis_site/apps/api/tests/migrations/__init__.py delete mode 100644 pydis_site/apps/api/tests/migrations/base.py delete mode 100644 pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py delete mode 100644 pydis_site/apps/api/tests/migrations/test_base.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/migrations/__init__.py b/pydis_site/apps/api/tests/migrations/__init__.py deleted file mode 100644 index 38e42ffc..00000000 --- a/pydis_site/apps/api/tests/migrations/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""This submodule contains tests for functions used in data migrations.""" diff --git a/pydis_site/apps/api/tests/migrations/base.py b/pydis_site/apps/api/tests/migrations/base.py deleted file mode 100644 index 0c0a5bd0..00000000 --- a/pydis_site/apps/api/tests/migrations/base.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Includes utilities for testing migrations.""" -from django.db import connection -from django.db.migrations.executor import MigrationExecutor -from django.test import TestCase - - -class MigrationsTestCase(TestCase): - """ - A `TestCase` subclass to test migration files. - - To be able to properly test a migration, we will need to inject data into the test database - before the migrations we want to test are applied, but after the older migrations have been - applied. This makes sure that we are testing "as if" we were actually applying this migration - to a database in the state it was in before introducing the new migration. - - To set up a MigrationsTestCase, create a subclass of this class and set the following - class-level attributes: - - - app: The name of the app that contains the migrations (e.g., `'api'`) - - migration_prior: The name* of the last migration file before the migrations you want to test - - migration_target: The name* of the last migration file we want to test - - *) Specify the file names without a path or the `.py` file extension. - - Additionally, overwrite the `setUpMigrationData` in the subclass to inject data into the - database before the migrations we want to test are applied. Please read the docstring of the - method for more information. An optional hook, `setUpPostMigrationData` is also provided. - """ - - # These class-level attributes should be set in classes that inherit from this base class. - app = None - migration_prior = None - migration_target = None - - @classmethod - def setUpTestData(cls): - """ - Injects data into the test database prior to the migration we're trying to test. - - This class methods reverts the test database back to the state of the last migration file - prior to the migrations we want to test. It will then allow the user to inject data into the - test database by calling the `setUpMigrationData` hook. After the data has been injected, it - will apply the migrations we want to test and call the `setUpPostMigrationData` hook. The - user can now test if the migration correctly migrated the injected test data. - """ - if not cls.app: - raise ValueError("The `app` attribute was not set.") - - if not cls.migration_prior or not cls.migration_target: - raise ValueError("Both ` migration_prior` and `migration_target` need to be set.") - - cls.migrate_from = [(cls.app, cls.migration_prior)] - cls.migrate_to = [(cls.app, cls.migration_target)] - - # Reverse to database state prior to the migrations we want to test - executor = MigrationExecutor(connection) - executor.migrate(cls.migrate_from) - - # Call the data injection hook with the current state of the project - old_apps = executor.loader.project_state(cls.migrate_from).apps - cls.setUpMigrationData(old_apps) - - # Run the migrations we want to test - executor = MigrationExecutor(connection) - executor.loader.build_graph() - executor.migrate(cls.migrate_to) - - # Save the project state so we're able to work with the correct model states - cls.apps = executor.loader.project_state(cls.migrate_to).apps - - # Call `setUpPostMigrationData` to potentially set up post migration data used in testing - cls.setUpPostMigrationData(cls.apps) - - @classmethod - def setUpMigrationData(cls, apps): - """ - Override this method to inject data into the test database before the migration is applied. - - This method will be called after setting up the database according to the migrations that - come before the migration(s) we are trying to test, but before the to-be-tested migration(s) - are applied. This allows us to simulate a database state just prior to the migrations we are - trying to test. - - To make sure we're creating objects according to the state the models were in at this point - in the migration history, use `apps.get_model(app_name: str, model_name: str)` to get the - appropriate model, e.g.: - - >>> Infraction = apps.get_model('api', 'Infraction') - """ - pass - - @classmethod - def setUpPostMigrationData(cls, apps): - """ - Set up additional test data after the target migration has been applied. - - Use `apps.get_model(app_name: str, model_name: str)` to get the correct instances of the - model classes: - - >>> Infraction = apps.get_model('api', 'Infraction') - """ - pass diff --git a/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py b/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py deleted file mode 100644 index 8dc29b34..00000000 --- a/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py +++ /dev/null @@ -1,496 +0,0 @@ -"""Tests for the data migration in `filename`.""" -import logging -from collections import ChainMap, namedtuple -from datetime import timedelta -from itertools import count -from typing import Dict, Iterable, Type, Union - -from django.db.models import Q -from django.forms.models import model_to_dict -from django.utils import timezone - -from pydis_site.apps.api.models import Infraction, User -from .base import MigrationsTestCase - -log = logging.getLogger(__name__) -log.setLevel(logging.DEBUG) - - -InfractionHistory = namedtuple('InfractionHistory', ("user_id", "infraction_history")) - - -class InfractionFactory: - """Factory that creates infractions for a User instance.""" - - infraction_id = count(1) - user_id = count(1) - default_values = { - 'active': True, - 'expires_at': None, - 'hidden': False, - } - - @classmethod - def create( - cls, - actor: User, - infractions: Iterable[Dict[str, Union[str, int, bool]]], - infraction_model: Type[Infraction] = Infraction, - user_model: Type[User] = User, - ) -> InfractionHistory: - """ - Creates `infractions` for the `user` with the given `actor`. - - The `infractions` dictionary can contain the following fields: - - `type` (required) - - `active` (default: True) - - `expires_at` (default: None; i.e, permanent) - - `hidden` (default: False). - - The parameters `infraction_model` and `user_model` can be used to pass in an instance of - both model classes from a different migration/project state. - """ - user_id = next(cls.user_id) - user = user_model.objects.create( - id=user_id, - name=f"Infracted user {user_id}", - discriminator=user_id, - avatar_hash=None, - ) - infraction_history = [] - - for infraction in infractions: - infraction = dict(infraction) - infraction["id"] = next(cls.infraction_id) - infraction = ChainMap(infraction, cls.default_values) - new_infraction = infraction_model.objects.create( - user=user, - actor=actor, - type=infraction["type"], - reason=f"`{infraction['type']}` infraction (ID: {infraction['id']} of {user}", - active=infraction['active'], - hidden=infraction['hidden'], - expires_at=infraction['expires_at'], - ) - infraction_history.append(new_infraction) - - return InfractionHistory(user_id=user_id, infraction_history=infraction_history) - - -class InfractionFactoryTests(MigrationsTestCase): - """Tests for the InfractionFactory.""" - - app = "api" - migration_prior = "0046_reminder_jump_url" - migration_target = "0046_reminder_jump_url" - - @classmethod - def setUpPostMigrationData(cls, apps): - """Create a default actor for all infractions.""" - cls.infraction_model = apps.get_model('api', 'Infraction') - cls.user_model = apps.get_model('api', 'User') - - cls.actor = cls.user_model.objects.create( - id=9999, - name="Unknown Moderator", - discriminator=1040, - avatar_hash=None, - ) - - def test_infraction_factory_total_count(self): - """Does the test database hold as many infractions as we tried to create?""" - InfractionFactory.create( - actor=self.actor, - infractions=( - {'type': 'kick', 'active': False, 'hidden': False}, - {'type': 'ban', 'active': True, 'hidden': False}, - {'type': 'note', 'active': False, 'hidden': True}, - ), - infraction_model=self.infraction_model, - user_model=self.user_model, - ) - database_count = Infraction.objects.all().count() - self.assertEqual(3, database_count) - - def test_infraction_factory_multiple_users(self): - """Does the test database hold as many infractions as we tried to create?""" - for _user in range(5): - InfractionFactory.create( - actor=self.actor, - infractions=( - {'type': 'kick', 'active': False, 'hidden': True}, - {'type': 'ban', 'active': True, 'hidden': False}, - ), - infraction_model=self.infraction_model, - user_model=self.user_model, - ) - - # Check if infractions and users are recorded properly in the database - database_count = Infraction.objects.all().count() - self.assertEqual(database_count, 10) - - user_count = User.objects.all().count() - self.assertEqual(user_count, 5 + 1) - - def test_infraction_factory_sets_correct_fields(self): - """Does the InfractionFactory set the correct attributes?""" - infractions = ( - { - 'type': 'note', - 'active': False, - 'hidden': True, - 'expires_at': timezone.now() - }, - {'type': 'warning', 'active': False, 'hidden': False, 'expires_at': None}, - {'type': 'watch', 'active': False, 'hidden': True, 'expires_at': None}, - {'type': 'mute', 'active': True, 'hidden': False, 'expires_at': None}, - {'type': 'kick', 'active': True, 'hidden': True, 'expires_at': None}, - {'type': 'ban', 'active': True, 'hidden': False, 'expires_at': None}, - { - 'type': 'superstar', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() - }, - ) - - InfractionFactory.create( - actor=self.actor, - infractions=infractions, - infraction_model=self.infraction_model, - user_model=self.user_model, - ) - - for infraction in infractions: - with self.subTest(**infraction): - self.assertTrue(Infraction.objects.filter(**infraction).exists()) - - -class ActiveInfractionMigrationTests(MigrationsTestCase): - """ - Tests the active infraction data migration. - - The active infraction data migration should do the following things: - - 1. migrates all active notes, warnings, and kicks to an inactive status; - 2. migrates all users with multiple active infractions of a single type to have only one active - infraction of that type. The infraction with the longest duration stays active. - """ - - app = "api" - migration_prior = "0046_reminder_jump_url" - migration_target = "0047_active_infractions_migration" - - @classmethod - def setUpMigrationData(cls, apps): - """Sets up an initial database state that contains the relevant test cases.""" - # Fetch the Infraction and User model in the current migration state - cls.infraction_model = apps.get_model('api', 'Infraction') - cls.user_model = apps.get_model('api', 'User') - - cls.created_infractions = {} - - # Moderator that serves as actor for all infractions - cls.user_moderator = cls.user_model.objects.create( - id=9999, - name="Olivier de Vienne", - discriminator=1040, - avatar_hash=None, - ) - - # User #1: clean user with no infractions - cls.created_infractions["no infractions"] = InfractionFactory.create( - actor=cls.user_moderator, - infractions=[], - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - - # User #2: One inactive note infraction - cls.created_infractions["one inactive note"] = InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'note', 'active': False, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - - # User #3: One active note infraction - cls.created_infractions["one active note"] = InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'note', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - - # User #4: One active and one inactive note infraction - cls.created_infractions["one active and one inactive note"] = InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'note', 'active': False, 'hidden': True}, - {'type': 'note', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - - # User #5: Once active note, one active kick, once active warning - cls.created_infractions["active note, kick, warning"] = InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'note', 'active': True, 'hidden': True}, - {'type': 'kick', 'active': True, 'hidden': True}, - {'type': 'warning', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - - # User #6: One inactive ban and one active ban - cls.created_infractions["one inactive and one active ban"] = InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'ban', 'active': False, 'hidden': True}, - {'type': 'ban', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - - # User #7: Two active permanent bans - cls.created_infractions["two active perm bans"] = InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'ban', 'active': True, 'hidden': True}, - {'type': 'ban', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - - # User #8: Multiple active temporary bans - cls.created_infractions["multiple active temp bans"] = InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=1) - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=10) - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=20) - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=5) - }, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - - # User #9: One active permanent ban, two active temporary bans - cls.created_infractions["active perm, two active temp bans"] = InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=10) - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': None, - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=7) - }, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - - # User #10: One inactive permanent ban, two active temporary bans - cls.created_infractions["one inactive perm ban, two active temp bans"] = ( - InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=10) - }, - { - 'type': 'ban', - 'active': False, - 'hidden': True, - 'expires_at': None, - }, - { - 'type': 'ban', - 'active': True, - 'hidden': True, - 'expires_at': timezone.now() + timedelta(days=7) - }, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - ) - - # User #11: Active ban, active mute, active superstar - cls.created_infractions["active ban, mute, and superstar"] = InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'ban', 'active': True, 'hidden': True}, - {'type': 'mute', 'active': True, 'hidden': True}, - {'type': 'superstar', 'active': True, 'hidden': True}, - {'type': 'watch', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - - # User #12: Multiple active bans, active mutes, active superstars - cls.created_infractions["multiple active bans, mutes, stars"] = InfractionFactory.create( - actor=cls.user_moderator, - infractions=( - {'type': 'ban', 'active': True, 'hidden': True}, - {'type': 'ban', 'active': True, 'hidden': True}, - {'type': 'ban', 'active': True, 'hidden': True}, - {'type': 'mute', 'active': True, 'hidden': True}, - {'type': 'mute', 'active': True, 'hidden': True}, - {'type': 'mute', 'active': True, 'hidden': True}, - {'type': 'superstar', 'active': True, 'hidden': True}, - {'type': 'superstar', 'active': True, 'hidden': True}, - {'type': 'superstar', 'active': True, 'hidden': True}, - {'type': 'watch', 'active': True, 'hidden': True}, - {'type': 'watch', 'active': True, 'hidden': True}, - {'type': 'watch', 'active': True, 'hidden': True}, - ), - infraction_model=cls.infraction_model, - user_model=cls.user_model, - ) - - def test_all_never_active_types_became_inactive(self): - """Are all infractions of a non-active type inactive after the migration?""" - inactive_type_query = Q(type="note") | Q(type="warning") | Q(type="kick") - self.assertFalse( - self.infraction_model.objects.filter(inactive_type_query, active=True).exists() - ) - - def test_migration_left_clean_user_without_infractions(self): - """Do users without infractions have no infractions after the migration?""" - user_id, infraction_history = self.created_infractions["no infractions"] - self.assertFalse( - self.infraction_model.objects.filter(user__id=user_id).exists() - ) - - def test_migration_left_user_with_inactive_note_untouched(self): - """Did the migration leave users with only an inactive note untouched?""" - user_id, infraction_history = self.created_infractions["one inactive note"] - inactive_note = infraction_history[0] - self.assertTrue( - self.infraction_model.objects.filter(**model_to_dict(inactive_note)).exists() - ) - - def test_migration_only_touched_active_field_of_active_note(self): - """Does the migration only change the `active` field?""" - user_id, infraction_history = self.created_infractions["one active note"] - note = model_to_dict(infraction_history[0]) - note['active'] = False - self.assertTrue( - self.infraction_model.objects.filter(**note).exists() - ) - - def test_migration_only_touched_active_field_of_active_note_left_inactive_untouched(self): - """Does the migration only change the `active` field of active notes?""" - user_id, infraction_history = self.created_infractions["one active and one inactive note"] - for note in infraction_history: - with self.subTest(active=note.active): - note = model_to_dict(note) - note['active'] = False - self.assertTrue( - self.infraction_model.objects.filter(**note).exists() - ) - - def test_migration_migrates_all_nonactive_types_to_inactive(self): - """Do we set the `active` field of all non-active infractions to `False`?""" - user_id, infraction_history = self.created_infractions["active note, kick, warning"] - self.assertFalse( - self.infraction_model.objects.filter(user__id=user_id, active=True).exists() - ) - - def test_migration_leaves_user_with_one_active_ban_untouched(self): - """Do we leave a user with one active and one inactive ban untouched?""" - user_id, infraction_history = self.created_infractions["one inactive and one active ban"] - for infraction in infraction_history: - with self.subTest(active=infraction.active): - self.assertTrue( - self.infraction_model.objects.filter(**model_to_dict(infraction)).exists() - ) - - def test_migration_turns_double_active_perm_ban_into_single_active_perm_ban(self): - """Does the migration turn two active permanent bans into one active permanent ban?""" - user_id, infraction_history = self.created_infractions["two active perm bans"] - active_count = self.infraction_model.objects.filter(user__id=user_id, active=True).count() - self.assertEqual(active_count, 1) - - def test_migration_leaves_temporary_ban_with_longest_duration_active(self): - """Does the migration turn two active permanent bans into one active permanent ban?""" - user_id, infraction_history = self.created_infractions["multiple active temp bans"] - active_ban = self.infraction_model.objects.get(user__id=user_id, active=True) - self.assertEqual(active_ban.expires_at, infraction_history[2].expires_at) - - def test_migration_leaves_permanent_ban_active(self): - """Does the migration leave the permanent ban active?""" - user_id, infraction_history = self.created_infractions["active perm, two active temp bans"] - active_ban = self.infraction_model.objects.get(user__id=user_id, active=True) - self.assertIsNone(active_ban.expires_at) - - def test_migration_leaves_longest_temp_ban_active_with_inactive_permanent_ban(self): - """Does the longest temp ban stay active, even with an inactive perm ban present?""" - user_id, infraction_history = self.created_infractions[ - "one inactive perm ban, two active temp bans" - ] - active_ban = self.infraction_model.objects.get(user__id=user_id, active=True) - self.assertEqual(active_ban.expires_at, infraction_history[0].expires_at) - - def test_migration_leaves_all_active_types_active_if_one_of_each_exists(self): - """Do all active infractions stay active if only one of each is present?""" - user_id, infraction_history = self.created_infractions["active ban, mute, and superstar"] - active_count = self.infraction_model.objects.filter(user__id=user_id, active=True).count() - self.assertEqual(active_count, 4) - - def test_migration_reduces_all_active_types_to_a_single_active_infraction(self): - """Do we reduce all of the infraction types to one active infraction?""" - user_id, infraction_history = self.created_infractions["multiple active bans, mutes, stars"] - active_infractions = self.infraction_model.objects.filter(user__id=user_id, active=True) - self.assertEqual(len(active_infractions), 4) - types_observed = [infraction.type for infraction in active_infractions] - - for infraction_type in ('ban', 'mute', 'superstar', 'watch'): - with self.subTest(type=infraction_type): - self.assertIn(infraction_type, types_observed) diff --git a/pydis_site/apps/api/tests/migrations/test_base.py b/pydis_site/apps/api/tests/migrations/test_base.py deleted file mode 100644 index f69bc92c..00000000 --- a/pydis_site/apps/api/tests/migrations/test_base.py +++ /dev/null @@ -1,135 +0,0 @@ -import logging -from unittest.mock import call, patch - -from django.db.migrations.loader import MigrationLoader -from django.test import TestCase - -from .base import MigrationsTestCase, connection - -log = logging.getLogger(__name__) - - -class SpanishInquisition(MigrationsTestCase): - app = "api" - migration_prior = "scragly" - migration_target = "kosa" - - -@patch("pydis_site.apps.api.tests.migrations.base.MigrationExecutor") -class MigrationsTestCaseNoSideEffectsTests(TestCase): - """Tests the MigrationTestCase class with actual migration side effects disabled.""" - - def setUp(self): - """Set up an instance of MigrationsTestCase for use in tests.""" - self.test_case = SpanishInquisition() - - def test_missing_app_class_raises_value_error(self, _migration_executor): - """A MigrationsTestCase subclass should set the class-attribute `app`.""" - class Spam(MigrationsTestCase): - pass - - spam = Spam() - with self.assertRaises(ValueError, msg="The `app` attribute was not set."): - spam.setUpTestData() - - def test_missing_migration_class_attributes_raise_value_error(self, _migration_executor): - """A MigrationsTestCase subclass should set both `migration_prior` and `migration_target`""" - class Eggs(MigrationsTestCase): - app = "api" - migration_target = "lemon" - - class Bacon(MigrationsTestCase): - app = "api" - migration_prior = "mark" - - instances = (Eggs(), Bacon()) - - exception_message = "Both ` migration_prior` and `migration_target` need to be set." - for instance in instances: - with self.subTest( - migration_prior=instance.migration_prior, - migration_target=instance.migration_target, - ): - with self.assertRaises(ValueError, msg=exception_message): - instance.setUpTestData() - - @patch(f"{__name__}.SpanishInquisition.setUpMigrationData") - @patch(f"{__name__}.SpanishInquisition.setUpPostMigrationData") - def test_migration_data_hooks_are_called_once(self, pre_hook, post_hook, _migration_executor): - """The `setUpMigrationData` and `setUpPostMigrationData` hooks should be called once.""" - self.test_case.setUpTestData() - for hook in (pre_hook, post_hook): - with self.subTest(hook=repr(hook)): - hook.assert_called_once() - - def test_migration_executor_is_instantiated_twice(self, migration_executor): - """The `MigrationExecutor` should be instantiated with the database connection twice.""" - self.test_case.setUpTestData() - - expected_args = [call(connection), call(connection)] - self.assertEqual(migration_executor.call_args_list, expected_args) - - def test_project_state_is_loaded_for_correct_migration_files_twice(self, migration_executor): - """The `project_state` should first be loaded with `migrate_from`, then `migrate_to`.""" - self.test_case.setUpTestData() - - expected_args = [call(self.test_case.migrate_from), call(self.test_case.migrate_to)] - self.assertEqual(migration_executor().loader.project_state.call_args_list, expected_args) - - def test_loader_build_graph_gets_called_once(self, migration_executor): - """We should rebuild the migration graph before applying the second set of migrations.""" - self.test_case.setUpTestData() - - migration_executor().loader.build_graph.assert_called_once() - - def test_migration_executor_migrate_method_is_called_correctly_twice(self, migration_executor): - """The migrate method of the executor should be called twice with the correct arguments.""" - self.test_case.setUpTestData() - - self.assertEqual(migration_executor().migrate.call_count, 2) - calls = [call([('api', 'scragly')]), call([('api', 'kosa')])] - migration_executor().migrate.assert_has_calls(calls) - - -class LifeOfBrian(MigrationsTestCase): - app = "api" - migration_prior = "0046_reminder_jump_url" - migration_target = "0048_add_infractions_unique_constraints_active" - - @classmethod - def log_last_migration(cls): - """Parses the applied migrations dictionary to log the last applied migration.""" - loader = MigrationLoader(connection) - api_migrations = [ - migration for app, migration in loader.applied_migrations if app == cls.app - ] - last_migration = max(api_migrations, key=lambda name: int(name[:4])) - log.info(f"The last applied migration: {last_migration}") - - @classmethod - def setUpMigrationData(cls, apps): - """Method that logs the last applied migration at this point.""" - cls.log_last_migration() - - @classmethod - def setUpPostMigrationData(cls, apps): - """Method that logs the last applied migration at this point.""" - cls.log_last_migration() - - -class MigrationsTestCaseMigrationTest(TestCase): - """Tests if `MigrationsTestCase` travels to the right points in the migration history.""" - - def test_migrations_test_case_travels_to_correct_migrations_in_history(self): - """The test case should first revert to `migration_prior`, then go to `migration_target`.""" - brian = LifeOfBrian() - - with self.assertLogs(log, level=logging.INFO) as logs: - brian.setUpTestData() - - self.assertEqual(len(logs.records), 2) - - for time_point, record in zip(("migration_prior", "migration_target"), logs.records): - with self.subTest(time_point=time_point): - message = f"The last applied migration: {getattr(brian, time_point)}" - self.assertEqual(record.getMessage(), message) -- cgit v1.2.3 From ee25921da752d51215598bcd3eb5fd5ab74a4a46 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 11 Jul 2022 05:00:40 +0400 Subject: Remove Message Model Test The message model was tested by instantiating and confirming it has a string representation, but instantiating abstract models is undefined behavior, and can break with future versions of django. The behavior of the test is redundant anyway, since an abstract model wouldn't exist in isolation, and the desired behavior is confirmed by inheritors. Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/tests/test_models.py | 12 ------------ 1 file changed, 12 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index 0fad467c..c07d59cd 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -7,7 +7,6 @@ from pydis_site.apps.api.models import ( DeletedMessage, DocumentationLink, Infraction, - Message, MessageDeletionContext, Nomination, NominationEntry, @@ -116,17 +115,6 @@ class StringDunderMethodTests(SimpleTestCase): colour=0x5, permissions=0, position=10, ), - Message( - id=45, - author=User( - id=444, - name='bill', - discriminator=5, - ), - channel_id=666, - content="wooey", - embeds=[] - ), MessageDeletionContext( actor=User( id=5555, -- cgit v1.2.3 From 36a461b57c5b901fb5cfb81966bac3f0387fd590 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 11 Jul 2022 05:05:51 +0400 Subject: Bump flake8-bandit To v3 Bumps flake-bandit to v3 to fix an incompatibility with the bandit package. This also bumps flake8-annotations to a legally acceptable version, which introduces ANN401, which disallows `typing.Any` annotations (for the most part, refer to the docs). Signed-off-by: Hassan Abouelela --- poetry.lock | 271 +++++++++++-------------- pydis_site/apps/api/pagination.py | 5 +- pydis_site/apps/content/views/page_category.py | 4 +- pyproject.toml | 2 +- 4 files changed, 120 insertions(+), 162 deletions(-) (limited to 'pydis_site/apps') diff --git a/poetry.lock b/poetry.lock index fcdc9d84..826c4fca 100644 --- a/poetry.lock +++ b/poetry.lock @@ -17,7 +17,7 @@ trio = ["trio (>=0.16)"] [[package]] name = "asgiref" -version = "3.5.0" +version = "3.5.2" description = "ASGI specs, helper code, and adapters" category = "main" optional = false @@ -42,7 +42,7 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (> [[package]] name = "bandit" -version = "1.7.2" +version = "1.7.4" description = "Security oriented static analyser for python code." category = "dev" optional = false @@ -55,17 +55,17 @@ PyYAML = ">=5.3.1" stevedore = ">=1.20.0" [package.extras] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml"] +test = ["coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml", "beautifulsoup4 (>=4.8.0)", "pylint (==1.9.4)"] toml = ["toml"] yaml = ["pyyaml"] [[package]] name = "certifi" -version = "2021.10.8" +version = "2022.6.15" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "cfgv" @@ -77,18 +77,18 @@ python-versions = ">=3.6.1" [[package]] name = "charset-normalizer" -version = "2.0.11" +version = "2.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false -python-versions = ">=3.5.0" +python-versions = ">=3.6.0" [package.extras] unicode_backport = ["unicodedata2"] [[package]] name = "colorama" -version = "0.4.4" +version = "0.4.5" description = "Cross-platform colored terminal text." category = "dev" optional = false @@ -190,7 +190,7 @@ prometheus-client = ">=0.7" [[package]] name = "django-simple-bulma" -version = "2.4.0" +version = "2.5.0" description = "Django application to add the Bulma CSS framework and its extensions" category = "main" optional = false @@ -224,7 +224,7 @@ python-versions = "*" [[package]] name = "filelock" -version = "3.4.2" +version = "3.7.1" description = "A platform independent file lock." category = "dev" optional = false @@ -249,25 +249,26 @@ pyflakes = ">=2.3.0,<2.4.0" [[package]] name = "flake8-annotations" -version = "2.7.0" +version = "2.9.0" description = "Flake8 Type Annotation Checks" category = "dev" optional = false -python-versions = ">=3.6.2,<4.0.0" +python-versions = ">=3.7,<4.0" [package.dependencies] -flake8 = ">=3.7,<5.0" +attrs = ">=21.4,<22.0" +flake8 = ">=3.7" [[package]] name = "flake8-bandit" -version = "2.1.2" +version = "3.0.0" description = "Automated security testing with bandit and flake8." category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" [package.dependencies] -bandit = "*" +bandit = ">=1.7.3" flake8 = "*" flake8-polyfill = "*" pycodestyle = "*" @@ -334,7 +335,7 @@ flake8 = "*" [[package]] name = "flake8-tidy-imports" -version = "4.6.0" +version = "4.8.0" description = "A flake8 plugin that helps you write tidier imports." category = "dev" optional = false @@ -367,7 +368,7 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.26" +version = "3.1.27" description = "GitPython is a python library used to interact with Git repositories" category = "dev" optional = false @@ -438,7 +439,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"] [[package]] name = "identify" -version = "2.4.6" +version = "2.5.1" description = "File identification library for Python" category = "dev" optional = false @@ -457,7 +458,7 @@ python-versions = ">=3.5" [[package]] name = "importlib-metadata" -version = "4.10.1" +version = "4.12.0" description = "Read metadata from Python packages" category = "main" optional = false @@ -467,9 +468,9 @@ python-versions = ">=3.7" zipp = ">=0.5" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] [[package]] name = "libsass" @@ -484,7 +485,7 @@ six = "*" [[package]] name = "markdown" -version = "3.3.6" +version = "3.3.7" description = "Python implementation of Markdown." category = "main" optional = false @@ -514,15 +515,15 @@ python-versions = ">=3.5" [[package]] name = "nodeenv" -version = "1.6.0" +version = "1.7.0" description = "Node.js virtual environment builder" category = "dev" optional = false -python-versions = "*" +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" [[package]] name = "pbr" -version = "5.8.0" +version = "5.9.0" description = "Python Build Reasonableness" category = "dev" optional = false @@ -530,7 +531,7 @@ python-versions = ">=2.6" [[package]] name = "pep8-naming" -version = "0.12.1" +version = "0.13.0" description = "Check PEP-8 naming conventions, plugin for flake8" category = "dev" optional = false @@ -538,27 +539,26 @@ python-versions = "*" [package.dependencies] flake8 = ">=3.9.1" -flake8-polyfill = ">=1.0.2,<2" [[package]] name = "platformdirs" -version = "2.4.1" +version = "2.5.2" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" [package.extras] -docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] -test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] [[package]] name = "pre-commit" -version = "2.17.0" +version = "2.19.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.7" [package.dependencies] cfgv = ">=2.0.0" @@ -570,7 +570,7 @@ virtualenv = ">=20.0.8" [[package]] name = "prometheus-client" -version = "0.13.1" +version = "0.14.1" description = "Python client for the Prometheus monitoring system." category = "main" optional = false @@ -581,14 +581,14 @@ twisted = ["twisted"] [[package]] name = "psutil" -version = "5.9.0" +version = "5.9.1" description = "Cross-platform lib for process and system monitoring in Python." category = "dev" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] -test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"] +test = ["ipaddress", "mock", "enum34", "pywin32", "wmi"] [[package]] name = "psycopg2-binary" @@ -622,7 +622,7 @@ toml = ["toml"] [[package]] name = "pyfakefs" -version = "4.5.4" +version = "4.5.6" description = "pyfakefs implements a fake file system that mocks the Python file system modules." category = "dev" optional = false @@ -664,7 +664,7 @@ test = ["pytest", "toml", "pyaml"] [[package]] name = "pytz" -version = "2021.3" +version = "2022.1" description = "World timezone definitions, modern and historical" category = "main" optional = false @@ -812,20 +812,20 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "urllib3" -version = "1.26.8" +version = "1.26.10" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" [package.extras] -brotli = ["brotlipy (>=0.6.0)"] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.13.0" +version = "20.15.1" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -854,20 +854,20 @@ brotli = ["brotli"] [[package]] name = "zipp" -version = "3.7.0" +version = "3.8.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false python-versions = ">=3.7" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "c94949e29f868689d9c99379dbf4f9479e2ddfe5e6d49e15b57d016210a50379" +content-hash = "91913e2e96ab2e0e78a09334241062359605135d64f458e710f66d00fb670e05" [metadata.files] anyio = [ @@ -875,32 +875,26 @@ anyio = [ {file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"}, ] asgiref = [ - {file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"}, - {file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"}, + {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, + {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, ] attrs = [ {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] -bandit = [ - {file = "bandit-1.7.2-py3-none-any.whl", hash = "sha256:e20402cadfd126d85b68ed4c8862959663c8c372dbbb1fca8f8e2c9f55a067ec"}, - {file = "bandit-1.7.2.tar.gz", hash = "sha256:6d11adea0214a43813887bfe71a377b5a9955e4c826c8ffd341b494e3ab25260"}, -] +bandit = [] certifi = [ - {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, - {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, + {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, + {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, ] cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] -charset-normalizer = [ - {file = "charset-normalizer-2.0.11.tar.gz", hash = "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c"}, - {file = "charset_normalizer-2.0.11-py3-none-any.whl", hash = "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45"}, -] +charset-normalizer = [] colorama = [ - {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, + {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, + {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, ] coverage = [ {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, @@ -983,10 +977,7 @@ django-prometheus = [ {file = "django-prometheus-2.2.0.tar.gz", hash = "sha256:240378a1307c408bd5fc85614a3a57f1ce633d4a222c9e291e2bbf325173b801"}, {file = "django_prometheus-2.2.0-py2.py3-none-any.whl", hash = "sha256:e6616770d8820b8834762764bf1b76ec08e1b98e72a6f359d488a2e15fe3537c"}, ] -django-simple-bulma = [ - {file = "django-simple-bulma-2.4.0.tar.gz", hash = "sha256:99a15261b0c61062a128af3c6a45da9c066d6a4a548c9063464e0fb7a5438aa1"}, - {file = "django_simple_bulma-2.4.0-py3-none-any.whl", hash = "sha256:95d5e26bebbf6a0184e33df844a0ff534bdfd91431e413d1a844d47a75c55fff"}, -] +django-simple-bulma = [] djangorestframework = [ {file = "djangorestframework-3.12.4-py3-none-any.whl", hash = "sha256:6d1d59f623a5ad0509fe0d6bfe93cbdfe17b8116ebc8eda86d45f6e16e819aaf"}, {file = "djangorestframework-3.12.4.tar.gz", hash = "sha256:f747949a8ddac876e879190df194b925c177cdeb725a099db1460872f7c0a7f2"}, @@ -994,21 +985,16 @@ djangorestframework = [ docopt = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, ] -filelock = [ - {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, - {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"}, -] +filelock = [] flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] flake8-annotations = [ - {file = "flake8-annotations-2.7.0.tar.gz", hash = "sha256:52e53c05b0c06cac1c2dec192ea2c36e85081238add3bd99421d56f574b9479b"}, - {file = "flake8_annotations-2.7.0-py3-none-any.whl", hash = "sha256:3edfbbfb58e404868834fe6ec3eaf49c139f64f0701259f707d043185545151e"}, -] -flake8-bandit = [ - {file = "flake8_bandit-2.1.2.tar.gz", hash = "sha256:687fc8da2e4a239b206af2e54a90093572a60d0954f3054e23690739b0b0de3b"}, + {file = "flake8-annotations-2.9.0.tar.gz", hash = "sha256:63fb3f538970b6a8dfd84125cf5af16f7b22e52d5032acb3b7eb23645ecbda9b"}, + {file = "flake8_annotations-2.9.0-py3-none-any.whl", hash = "sha256:84f46de2964cb18fccea968d9eafce7cf857e34d913d515120795b9af6498d56"}, ] +flake8-bandit = [] flake8-bugbear = [ {file = "flake8-bugbear-20.11.1.tar.gz", hash = "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538"}, {file = "flake8_bugbear-20.11.1-py36.py37.py38-none-any.whl", hash = "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703"}, @@ -1030,8 +1016,8 @@ flake8-string-format = [ {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, ] flake8-tidy-imports = [ - {file = "flake8-tidy-imports-4.6.0.tar.gz", hash = "sha256:3e193d8c4bb4492408a90e956d888b27eed14c698387c9b38230da3dad78058f"}, - {file = "flake8_tidy_imports-4.6.0-py3-none-any.whl", hash = "sha256:6ae9f55d628156e19d19f4c359dd5d3e95431a9bd514f5e2748c53c1398c66b2"}, + {file = "flake8-tidy-imports-4.8.0.tar.gz", hash = "sha256:df44f9c841b5dfb3a7a1f0da8546b319d772c2a816a1afefcce43e167a593d83"}, + {file = "flake8_tidy_imports-4.8.0-py3-none-any.whl", hash = "sha256:25bd9799358edefa0e010ce2c587b093c3aba942e96aeaa99b6d0500ae1bf09c"}, ] flake8-todo = [ {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"}, @@ -1041,8 +1027,8 @@ gitdb = [ {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"}, ] gitpython = [ - {file = "GitPython-3.1.26-py3-none-any.whl", hash = "sha256:26ac35c212d1f7b16036361ca5cff3ec66e11753a0d677fb6c48fa4e1a9dd8d6"}, - {file = "GitPython-3.1.26.tar.gz", hash = "sha256:fc8868f63a2e6d268fb25f481995ba185a85a66fcad126f039323ff6635669ee"}, + {file = "GitPython-3.1.27-py3-none-any.whl", hash = "sha256:5b68b000463593e05ff2b261acff0ff0972df8ab1b70d3cdbd41b546c8b8fc3d"}, + {file = "GitPython-3.1.27.tar.gz", hash = "sha256:1c885ce809e8ba2d88a29befeb385fcea06338d3640712b59ca623c220bb5704"}, ] gunicorn = [ {file = "gunicorn-20.0.4-py2.py3-none-any.whl", hash = "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"}, @@ -1055,17 +1041,14 @@ h11 = [ httpcore = [] httpx = [] identify = [ - {file = "identify-2.4.6-py2.py3-none-any.whl", hash = "sha256:cf06b1639e0dca0c184b1504d8b73448c99a68e004a80524c7923b95f7b6837c"}, - {file = "identify-2.4.6.tar.gz", hash = "sha256:233679e3f61a02015d4293dbccf16aa0e4996f868bd114688b8c124f18826706"}, + {file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"}, + {file = "identify-2.5.1.tar.gz", hash = "sha256:3d11b16f3fe19f52039fb7e39c9c884b21cb1b586988114fbe42671f03de3e82"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, ] -importlib-metadata = [ - {file = "importlib_metadata-4.10.1-py3-none-any.whl", hash = "sha256:899e2a40a8c4a1aec681feef45733de8a6c58f3f6a0dbed2eb6574b4387a77b6"}, - {file = "importlib_metadata-4.10.1.tar.gz", hash = "sha256:951f0d8a5b7260e9db5e41d429285b5f451e928479f19d80818878527d36e95e"}, -] +importlib-metadata = [] libsass = [ {file = "libsass-0.21.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:06c8776417fe930714bdc930a3d7e795ae3d72be6ac883ff72a1b8f7c49e5ffb"}, {file = "libsass-0.21.0-cp27-cp27m-win32.whl", hash = "sha256:a005f298f64624f313a3ac618ab03f844c71d84ae4f4a4aec4b68d2a4ffe75eb"}, @@ -1078,10 +1061,7 @@ libsass = [ {file = "libsass-0.21.0-cp38-abi3-macosx_12_0_arm64.whl", hash = "sha256:c9ec490609752c1d81ff6290da33485aa7cb6d7365ac665b74464c1b7d97f7da"}, {file = "libsass-0.21.0.tar.gz", hash = "sha256:d5ba529d9ce668be9380563279f3ffe988f27bc5b299c5a28453df2e0b0fbaf2"}, ] -markdown = [ - {file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"}, - {file = "Markdown-3.3.6.tar.gz", hash = "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006"}, -] +markdown = [] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, @@ -1090,63 +1070,51 @@ mslex = [ {file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"}, {file = "mslex-0.3.0.tar.gz", hash = "sha256:4a1ac3f25025cad78ad2fe499dd16d42759f7a3801645399cce5c404415daa97"}, ] -nodeenv = [ - {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, - {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, -] -pbr = [ - {file = "pbr-5.8.0-py2.py3-none-any.whl", hash = "sha256:176e8560eaf61e127817ef93d8a844803abb27a4d4637f0ff3bb783129be2e0a"}, - {file = "pbr-5.8.0.tar.gz", hash = "sha256:672d8ebee84921862110f23fcec2acea191ef58543d34dfe9ef3d9f13c31cddf"}, -] -pep8-naming = [ - {file = "pep8-naming-0.12.1.tar.gz", hash = "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"}, - {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"}, -] +nodeenv = [] +pbr = [] +pep8-naming = [] platformdirs = [ - {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, - {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, + {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, + {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, ] pre-commit = [ - {file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"}, - {file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"}, -] -prometheus-client = [ - {file = "prometheus_client-0.13.1-py3-none-any.whl", hash = "sha256:357a447fd2359b0a1d2e9b311a0c5778c330cfbe186d880ad5a6b39884652316"}, - {file = "prometheus_client-0.13.1.tar.gz", hash = "sha256:ada41b891b79fca5638bd5cfe149efa86512eaa55987893becd2c6d8d0a5dfc5"}, + {file = "pre_commit-2.19.0-py2.py3-none-any.whl", hash = "sha256:10c62741aa5704faea2ad69cb550ca78082efe5697d6f04e5710c3c229afdd10"}, + {file = "pre_commit-2.19.0.tar.gz", hash = "sha256:4233a1e38621c87d9dda9808c6606d7e7ba0e087cd56d3fe03202a01d2919615"}, ] +prometheus-client = [] psutil = [ - {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:55ce319452e3d139e25d6c3f85a1acf12d1607ddedea5e35fb47a552c051161b"}, - {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:7336292a13a80eb93c21f36bde4328aa748a04b68c13d01dfddd67fc13fd0618"}, - {file = "psutil-5.9.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:cb8d10461c1ceee0c25a64f2dd54872b70b89c26419e147a05a10b753ad36ec2"}, - {file = "psutil-5.9.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:7641300de73e4909e5d148e90cc3142fb890079e1525a840cf0dfd39195239fd"}, - {file = "psutil-5.9.0-cp27-none-win32.whl", hash = "sha256:ea42d747c5f71b5ccaa6897b216a7dadb9f52c72a0fe2b872ef7d3e1eacf3ba3"}, - {file = "psutil-5.9.0-cp27-none-win_amd64.whl", hash = "sha256:ef216cc9feb60634bda2f341a9559ac594e2eeaadd0ba187a4c2eb5b5d40b91c"}, - {file = "psutil-5.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90a58b9fcae2dbfe4ba852b57bd4a1dded6b990a33d6428c7614b7d48eccb492"}, - {file = "psutil-5.9.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d41f8b3e9ebb6b6110057e40019a432e96aae2008951121ba4e56040b84f3"}, - {file = "psutil-5.9.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:742c34fff804f34f62659279ed5c5b723bb0195e9d7bd9907591de9f8f6558e2"}, - {file = "psutil-5.9.0-cp310-cp310-win32.whl", hash = "sha256:8293942e4ce0c5689821f65ce6522ce4786d02af57f13c0195b40e1edb1db61d"}, - {file = "psutil-5.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9b51917c1af3fa35a3f2dabd7ba96a2a4f19df3dec911da73875e1edaf22a40b"}, - {file = "psutil-5.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e9805fed4f2a81de98ae5fe38b75a74c6e6ad2df8a5c479594c7629a1fe35f56"}, - {file = "psutil-5.9.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c51f1af02334e4b516ec221ee26b8fdf105032418ca5a5ab9737e8c87dafe203"}, - {file = "psutil-5.9.0-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32acf55cb9a8cbfb29167cd005951df81b567099295291bcfd1027365b36591d"}, - {file = "psutil-5.9.0-cp36-cp36m-win32.whl", hash = "sha256:e5c783d0b1ad6ca8a5d3e7b680468c9c926b804be83a3a8e95141b05c39c9f64"}, - {file = "psutil-5.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d62a2796e08dd024b8179bd441cb714e0f81226c352c802fca0fd3f89eeacd94"}, - {file = "psutil-5.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d00a664e31921009a84367266b35ba0aac04a2a6cad09c550a89041034d19a0"}, - {file = "psutil-5.9.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7779be4025c540d1d65a2de3f30caeacc49ae7a2152108adeaf42c7534a115ce"}, - {file = "psutil-5.9.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:072664401ae6e7c1bfb878c65d7282d4b4391f1bc9a56d5e03b5a490403271b5"}, - {file = "psutil-5.9.0-cp37-cp37m-win32.whl", hash = "sha256:df2c8bd48fb83a8408c8390b143c6a6fa10cb1a674ca664954de193fdcab36a9"}, - {file = "psutil-5.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1d7b433519b9a38192dfda962dd8f44446668c009833e1429a52424624f408b4"}, - {file = "psutil-5.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c3400cae15bdb449d518545cbd5b649117de54e3596ded84aacabfbb3297ead2"}, - {file = "psutil-5.9.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2237f35c4bbae932ee98902a08050a27821f8f6dfa880a47195e5993af4702d"}, - {file = "psutil-5.9.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1070a9b287846a21a5d572d6dddd369517510b68710fca56b0e9e02fd24bed9a"}, - {file = "psutil-5.9.0-cp38-cp38-win32.whl", hash = "sha256:76cebf84aac1d6da5b63df11fe0d377b46b7b500d892284068bacccf12f20666"}, - {file = "psutil-5.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:3151a58f0fbd8942ba94f7c31c7e6b310d2989f4da74fcbf28b934374e9bf841"}, - {file = "psutil-5.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:539e429da49c5d27d5a58e3563886057f8fc3868a5547b4f1876d9c0f007bccf"}, - {file = "psutil-5.9.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58c7d923dc209225600aec73aa2c4ae8ea33b1ab31bc11ef8a5933b027476f07"}, - {file = "psutil-5.9.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3611e87eea393f779a35b192b46a164b1d01167c9d323dda9b1e527ea69d697d"}, - {file = "psutil-5.9.0-cp39-cp39-win32.whl", hash = "sha256:4e2fb92e3aeae3ec3b7b66c528981fd327fb93fd906a77215200404444ec1845"}, - {file = "psutil-5.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:7d190ee2eaef7831163f254dc58f6d2e2a22e27382b936aab51c835fc080c3d3"}, - {file = "psutil-5.9.0.tar.gz", hash = "sha256:869842dbd66bb80c3217158e629d6fceaecc3a3166d3d1faee515b05dd26ca25"}, + {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:799759d809c31aab5fe4579e50addf84565e71c1dc9f1c31258f159ff70d3f87"}, + {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9272167b5f5fbfe16945be3db475b3ce8d792386907e673a209da686176552af"}, + {file = "psutil-5.9.1-cp27-cp27m-win32.whl", hash = "sha256:0904727e0b0a038830b019551cf3204dd48ef5c6868adc776e06e93d615fc5fc"}, + {file = "psutil-5.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e7e10454cb1ab62cc6ce776e1c135a64045a11ec4c6d254d3f7689c16eb3efd2"}, + {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:56960b9e8edcca1456f8c86a196f0c3d8e3e361320071c93378d41445ffd28b0"}, + {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:44d1826150d49ffd62035785a9e2c56afcea66e55b43b8b630d7706276e87f22"}, + {file = "psutil-5.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7be9d7f5b0d206f0bbc3794b8e16fb7dbc53ec9e40bbe8787c6f2d38efcf6c9"}, + {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd9246e4cdd5b554a2ddd97c157e292ac11ef3e7af25ac56b08b455c829dca8"}, + {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29a442e25fab1f4d05e2655bb1b8ab6887981838d22effa2396d584b740194de"}, + {file = "psutil-5.9.1-cp310-cp310-win32.whl", hash = "sha256:20b27771b077dcaa0de1de3ad52d22538fe101f9946d6dc7869e6f694f079329"}, + {file = "psutil-5.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:58678bbadae12e0db55186dc58f2888839228ac9f41cc7848853539b70490021"}, + {file = "psutil-5.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a76ad658641172d9c6e593de6fe248ddde825b5866464c3b2ee26c35da9d237"}, + {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6a11e48cb93a5fa606306493f439b4aa7c56cb03fc9ace7f6bfa21aaf07c453"}, + {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068935df39055bf27a29824b95c801c7a5130f118b806eee663cad28dca97685"}, + {file = "psutil-5.9.1-cp36-cp36m-win32.whl", hash = "sha256:0f15a19a05f39a09327345bc279c1ba4a8cfb0172cc0d3c7f7d16c813b2e7d36"}, + {file = "psutil-5.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:db417f0865f90bdc07fa30e1aadc69b6f4cad7f86324b02aa842034efe8d8c4d"}, + {file = "psutil-5.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:91c7ff2a40c373d0cc9121d54bc5f31c4fa09c346528e6a08d1845bce5771ffc"}, + {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fea896b54f3a4ae6f790ac1d017101252c93f6fe075d0e7571543510f11d2676"}, + {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3054e923204b8e9c23a55b23b6df73a8089ae1d075cb0bf711d3e9da1724ded4"}, + {file = "psutil-5.9.1-cp37-cp37m-win32.whl", hash = "sha256:d2d006286fbcb60f0b391741f520862e9b69f4019b4d738a2a45728c7e952f1b"}, + {file = "psutil-5.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b14ee12da9338f5e5b3a3ef7ca58b3cba30f5b66f7662159762932e6d0b8f680"}, + {file = "psutil-5.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:19f36c16012ba9cfc742604df189f2f28d2720e23ff7d1e81602dbe066be9fd1"}, + {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:944c4b4b82dc4a1b805329c980f270f170fdc9945464223f2ec8e57563139cf4"}, + {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b6750a73a9c4a4e689490ccb862d53c7b976a2a35c4e1846d049dcc3f17d83b"}, + {file = "psutil-5.9.1-cp38-cp38-win32.whl", hash = "sha256:a8746bfe4e8f659528c5c7e9af5090c5a7d252f32b2e859c584ef7d8efb1e689"}, + {file = "psutil-5.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:79c9108d9aa7fa6fba6e668b61b82facc067a6b81517cab34d07a84aa89f3df0"}, + {file = "psutil-5.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28976df6c64ddd6320d281128817f32c29b539a52bdae5e192537bc338a9ec81"}, + {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b88f75005586131276634027f4219d06e0561292be8bd6bc7f2f00bdabd63c4e"}, + {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:645bd4f7bb5b8633803e0b6746ff1628724668681a434482546887d22c7a9537"}, + {file = "psutil-5.9.1-cp39-cp39-win32.whl", hash = "sha256:32c52611756096ae91f5d1499fe6c53b86f4a9ada147ee42db4991ba1520e574"}, + {file = "psutil-5.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:f65f9a46d984b8cd9b3750c2bdb419b2996895b005aefa6cbaba9a143b1ce2c5"}, + {file = "psutil-5.9.1.tar.gz", hash = "sha256:57f1819b5d9e95cdfb0c881a8a5b7d542ed0b7c522d575706a80bedc848c8954"}, ] psycopg2-binary = [ {file = "psycopg2-binary-2.8.6.tar.gz", hash = "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0"}, @@ -1193,10 +1161,7 @@ pydocstyle = [ {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, ] -pyfakefs = [ - {file = "pyfakefs-4.5.4-py3-none-any.whl", hash = "sha256:e0cc0d22cb74badf4fb2143a112817d7aea1a58ee9dca015a68bf38c3691cb52"}, - {file = "pyfakefs-4.5.4.tar.gz", hash = "sha256:5b5951e873f73bf12e3a19d8e4470c4b7962c51df753cf8c4caaf64e24a0a323"}, -] +pyfakefs = [] pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, @@ -1210,8 +1175,8 @@ python-frontmatter = [ {file = "python_frontmatter-1.0.0-py3-none-any.whl", hash = "sha256:766ae75f1b301ffc5fe3494339147e0fd80bc3deff3d7590a93991978b579b08"}, ] pytz = [ - {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, - {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, + {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, + {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, ] pyyaml = [ {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, @@ -1285,19 +1250,13 @@ toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] -urllib3 = [ - {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, - {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, -] -virtualenv = [ - {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, - {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, -] +urllib3 = [] +virtualenv = [] whitenoise = [ {file = "whitenoise-5.3.0-py2.py3-none-any.whl", hash = "sha256:d963ef25639d1417e8a247be36e6aedd8c7c6f0a08adcb5a89146980a96b577c"}, {file = "whitenoise-5.3.0.tar.gz", hash = "sha256:d234b871b52271ae7ed6d9da47ffe857c76568f11dd30e28e18c5869dbd11e12"}, ] zipp = [ - {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, - {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, + {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, + {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, ] diff --git a/pydis_site/apps/api/pagination.py b/pydis_site/apps/api/pagination.py index 2a325460..61707d33 100644 --- a/pydis_site/apps/api/pagination.py +++ b/pydis_site/apps/api/pagination.py @@ -1,7 +1,6 @@ -import typing - from rest_framework.pagination import LimitOffsetPagination from rest_framework.response import Response +from rest_framework.utils.serializer_helpers import ReturnList class LimitOffsetPaginationExtended(LimitOffsetPagination): @@ -44,6 +43,6 @@ class LimitOffsetPaginationExtended(LimitOffsetPagination): default_limit = 100 - def get_paginated_response(self, data: typing.Any) -> Response: + def get_paginated_response(self, data: ReturnList) -> Response: """Override to skip metadata i.e. `count`, `next`, and `previous`.""" return Response(data) diff --git a/pydis_site/apps/content/views/page_category.py b/pydis_site/apps/content/views/page_category.py index 5af77aff..356eb021 100644 --- a/pydis_site/apps/content/views/page_category.py +++ b/pydis_site/apps/content/views/page_category.py @@ -3,7 +3,7 @@ from pathlib import Path import frontmatter from django.conf import settings -from django.http import Http404 +from django.http import Http404, HttpRequest, HttpResponse from django.views.generic import TemplateView from pydis_site.apps.content import utils @@ -12,7 +12,7 @@ from pydis_site.apps.content import utils class PageOrCategoryView(TemplateView): """Handles pages and page categories.""" - def dispatch(self, request: t.Any, *args, **kwargs) -> t.Any: + def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """Conform URL path location to the filesystem path.""" self.location = Path(kwargs.get("location", "")) diff --git a/pyproject.toml b/pyproject.toml index 01e7eda2..6ef7c407 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ django-distill = "~=2.9.0" coverage = "~=5.0" flake8 = "~=3.7" flake8-annotations = "~=2.0" -flake8-bandit = "~=2.1" +flake8-bandit = "~=3.0" flake8-bugbear = "~=20.1" flake8-docstrings = "~=1.5" flake8-import-order = "~=0.18" -- cgit v1.2.3 From 4edb368c39c96f20e41a8874b0f77f30910c8ce7 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 11 Jul 2022 05:10:19 +0400 Subject: Bump Django To 3.2 Bumps the current django version to 3.2 For the most part, there are no major changes here. The app configuration names were incorrect, and the new version no longer ignores that, so those were updated. The new version also requires explicitly defining the field type for primary keys if they are not defined on the model. Signed-off-by: Hassan Abouelela --- poetry.lock | 13 +++++-------- pydis_site/apps/content/apps.py | 2 +- pydis_site/apps/events/apps.py | 2 +- pydis_site/apps/redirect/apps.py | 2 +- pydis_site/apps/resources/apps.py | 2 +- pydis_site/apps/staff/apps.py | 2 +- pydis_site/settings.py | 3 +++ pyproject.toml | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) (limited to 'pydis_site/apps') diff --git a/poetry.lock b/poetry.lock index 826c4fca..d855bdaf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -131,19 +131,19 @@ python-versions = "*" [[package]] name = "django" -version = "3.1.14" +version = "3.2.14" description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -asgiref = ">=3.2.10,<4" +asgiref = ">=3.3.2,<4" pytz = "*" sqlparse = ">=0.2.2" [package.extras] -argon2 = ["argon2-cffi (>=16.1.0)"] +argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] [[package]] @@ -867,7 +867,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "91913e2e96ab2e0e78a09334241062359605135d64f458e710f66d00fb670e05" +content-hash = "e40d87f94732c314e0fb3cd2c9023ea47d66d3c3d340085eb6df9c1fe1412529" [metadata.files] anyio = [ @@ -958,10 +958,7 @@ distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, ] -django = [ - {file = "Django-3.1.14-py3-none-any.whl", hash = "sha256:0fabc786489af16ad87a8c170ba9d42bfd23f7b699bd5ef05675864e8d012859"}, - {file = "Django-3.1.14.tar.gz", hash = "sha256:72a4a5a136a214c39cf016ccdd6b69e2aa08c7479c66d93f3a9b5e4bb9d8a347"}, -] +django = [] django-distill = [ {file = "django-distill-2.9.2.tar.gz", hash = "sha256:91d5f45c2ff78b8efd4805ff5ec17df4ba815bbf51ca12a2cea65727d2f1d42e"}, ] diff --git a/pydis_site/apps/content/apps.py b/pydis_site/apps/content/apps.py index 1e300a48..96019e1c 100644 --- a/pydis_site/apps/content/apps.py +++ b/pydis_site/apps/content/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class ContentConfig(AppConfig): """Django AppConfig for content app.""" - name = 'content' + name = 'pydis_site.apps.content' diff --git a/pydis_site/apps/events/apps.py b/pydis_site/apps/events/apps.py index a1cf09ef..70762bc2 100644 --- a/pydis_site/apps/events/apps.py +++ b/pydis_site/apps/events/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class EventsConfig(AppConfig): """Django AppConfig for events app.""" - name = 'events' + name = 'pydis_site.apps.events' diff --git a/pydis_site/apps/redirect/apps.py b/pydis_site/apps/redirect/apps.py index 9b70d169..0234bc93 100644 --- a/pydis_site/apps/redirect/apps.py +++ b/pydis_site/apps/redirect/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class RedirectConfig(AppConfig): """AppConfig instance for Redirect app.""" - name = 'redirect' + name = 'pydis_site.apps.redirect' diff --git a/pydis_site/apps/resources/apps.py b/pydis_site/apps/resources/apps.py index e0c235bd..93117654 100644 --- a/pydis_site/apps/resources/apps.py +++ b/pydis_site/apps/resources/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class ResourcesConfig(AppConfig): """AppConfig instance for Resources app.""" - name = 'resources' + name = 'pydis_site.apps.resources' diff --git a/pydis_site/apps/staff/apps.py b/pydis_site/apps/staff/apps.py index 70a15f40..d68a80c3 100644 --- a/pydis_site/apps/staff/apps.py +++ b/pydis_site/apps/staff/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class StaffConfig(AppConfig): """Django AppConfig for the staff app.""" - name = 'staff' + name = 'pydis_site.apps.staff' diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 17f220f3..03c16f4b 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -219,6 +219,9 @@ if DEBUG: else: PARENT_HOST = env('PARENT_HOST', default='pythondiscord.com') +# Django Model Configuration +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + # Django REST framework # https://www.django-rest-framework.org REST_FRAMEWORK = { diff --git a/pyproject.toml b/pyproject.toml index 6ef7c407..75292eb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "MIT" [tool.poetry.dependencies] python = "3.9.*" -django = "~=3.1.14" +django = "~=3.2.14" django-environ = "~=0.4.5" django-filter = "~=21.1" djangorestframework = "~=3.12.0" -- cgit v1.2.3 From fe4def75dc0a316789cec068a574713a2b2af92f Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 12 Jul 2022 09:25:05 +0400 Subject: Add GitHub Artifact API View Adds an API route to fetch GitHub build artifacts through a GitHub app. Signed-off-by: Hassan Abouelela --- .gitignore | 3 + poetry.lock | 67 +++++- pydis_site/apps/api/github_utils.py | 183 ++++++++++++++++ pydis_site/apps/api/tests/test_github_utils.py | 287 +++++++++++++++++++++++++ pydis_site/apps/api/urls.py | 9 +- pydis_site/apps/api/views.py | 53 +++++ pydis_site/settings.py | 10 +- pyproject.toml | 1 + 8 files changed, 609 insertions(+), 4 deletions(-) create mode 100644 pydis_site/apps/api/github_utils.py create mode 100644 pydis_site/apps/api/tests/test_github_utils.py (limited to 'pydis_site/apps') diff --git a/.gitignore b/.gitignore index 45073da5..4fc4417d 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ log.* # Mac/OSX .DS_Store + +# Private keys +*.pem diff --git a/poetry.lock b/poetry.lock index f6576fba..1bee4397 100644 --- a/poetry.lock +++ b/poetry.lock @@ -67,6 +67,17 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.3.1" @@ -121,6 +132,25 @@ requests = ">=1.0.0" [package.extras] yaml = ["PyYAML (>=3.10)"] +[[package]] +name = "cryptography" +version = "37.0.4" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +sdist = ["setuptools_rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] + [[package]] name = "distlib" version = "0.3.4" @@ -607,6 +637,14 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "pydocstyle" version = "6.1.1" @@ -637,6 +675,23 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "pyjwt" +version = "2.4.0" +description = "JSON Web Token implementation in Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cryptography = {version = ">=3.3.1", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["sphinx", "sphinx-rtd-theme", "zope.interface", "cryptography (>=3.3.1)", "pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)", "mypy", "pre-commit"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"] + [[package]] name = "python-dotenv" version = "0.17.1" @@ -876,7 +931,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "e71d10c3d478c5d99e842f4c449a093caa1d4b2d255eb0dfb19843c5265d4aca" +content-hash = "c656c07f40d32ee7d30c19a7084b40e1e851209a362a3fe882aa03c2fd286454" [metadata.files] anyio = [ @@ -896,6 +951,7 @@ certifi = [ {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, ] +cffi = [] cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, @@ -963,6 +1019,7 @@ coveralls = [ {file = "coveralls-2.2.0-py2.py3-none-any.whl", hash = "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc"}, {file = "coveralls-2.2.0.tar.gz", hash = "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"}, ] +cryptography = [] distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, @@ -1157,6 +1214,10 @@ pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] +pycparser = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] pydocstyle = [ {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, @@ -1166,6 +1227,10 @@ pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] +pyjwt = [ + {file = "PyJWT-2.4.0-py3-none-any.whl", hash = "sha256:72d1d253f32dbd4f5c88eaf1fdc62f3a19f676ccbadb9dbc5d07e951b2b26daf"}, + {file = "PyJWT-2.4.0.tar.gz", hash = "sha256:d42908208c699b3b973cbeb01a969ba6a96c821eefb1c5bfe4c390c01d67abba"}, +] python-dotenv = [ {file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"}, {file = "python_dotenv-0.17.1-py2.py3-none-any.whl", hash = "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544"}, diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py new file mode 100644 index 00000000..70dccdff --- /dev/null +++ b/pydis_site/apps/api/github_utils.py @@ -0,0 +1,183 @@ +"""Utilities for working with the GitHub API.""" + +import asyncio +import datetime +import math + +import httpx +import jwt +from asgiref.sync import async_to_sync + +from pydis_site import settings + +MAX_POLLS = 20 +"""The maximum number of attempts at fetching a workflow run.""" + + +class ArtifactProcessingError(Exception): + """Base exception for other errors related to processing a GitHub artifact.""" + + status: int + + +class UnauthorizedError(ArtifactProcessingError): + """The application does not have permission to access the requested repo.""" + + status = 401 + + +class NotFoundError(ArtifactProcessingError): + """The requested resource could not be found.""" + + status = 404 + + +class ActionFailedError(ArtifactProcessingError): + """The requested workflow did not conclude successfully.""" + + status = 400 + + +class RunTimeoutError(ArtifactProcessingError): + """The requested workflow run was not ready in time.""" + + status = 408 + + +def generate_token() -> str: + """ + Generate a JWT token to access the GitHub API. + + The token is valid for roughly 10 minutes after generation, before the API starts + returning 401s. + + Refer to: + https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app + """ + now = datetime.datetime.now() + return jwt.encode( + { + "iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()), # Issued at + "exp": math.floor((now + datetime.timedelta(minutes=9)).timestamp()), # Expires at + "iss": settings.GITHUB_OAUTH_APP_ID, + }, + settings.GITHUB_OAUTH_KEY, + algorithm="RS256" + ) + + +async def authorize(owner: str, repo: str) -> httpx.AsyncClient: + """ + Get an access token for the requested repository. + + The process is roughly: + - GET app/installations to get a list of all app installations + - POST to get a token to access the given app + - GET installation/repositories and check if the requested one is part of those + """ + client = httpx.AsyncClient( + base_url=settings.GITHUB_API, + headers={"Authorization": f"bearer {generate_token()}"}, + timeout=settings.TIMEOUT_PERIOD, + ) + + try: + # Get a list of app installations we have access to + apps = await client.get("app/installations") + apps.raise_for_status() + + for app in apps.json(): + # Look for an installation with the right owner + if app["account"]["login"] != owner: + continue + + # Get the repositories of the specified owner + app_token = await client.post(app["access_tokens_url"]) + app_token.raise_for_status() + client.headers["Authorization"] = f"bearer {app_token.json()['token']}" + + repos = await client.get("installation/repositories") + repos.raise_for_status() + + # Search for the request repository + for accessible_repo in repos.json()["repositories"]: + if accessible_repo["name"] == repo: + # We've found the correct repository, and it's accessible with the current auth + return client + + raise NotFoundError( + "Could not find the requested repository. Make sure the application can access it." + ) + + except BaseException as e: + # Close the client if we encountered an unexpected exception + await client.aclose() + raise e + + +async def wait_for_run(client: httpx.AsyncClient, run: dict) -> str: + """Wait for the provided `run` to finish, and return the URL to its artifacts.""" + polls = 0 + while polls <= MAX_POLLS: + if run["status"] != "completed": + # The action is still processing, wait a bit longer + polls += 1 + await asyncio.sleep(10) + + elif run["conclusion"] != "success": + # The action failed, or did not run + raise ActionFailedError(f"The requested workflow ended with: {run['conclusion']}") + + else: + # The desired action was found, and it ended successfully + return run["artifacts_url"] + + run = await client.get(run["url"]) + run.raise_for_status() + run = run.json() + + raise RunTimeoutError("The requested workflow was not ready in time.") + + +@async_to_sync +async def get_artifact( + owner: str, repo: str, sha: str, action_name: str, artifact_name: str +) -> str: + """Get a download URL for a build artifact.""" + client = await authorize(owner, repo) + + try: + # Get the workflow runs for this repository + runs = await client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100}) + runs.raise_for_status() + runs = runs.json() + + # Filter the runs for the one associated with the given SHA + for run in runs["workflow_runs"]: + if run["name"] == action_name and sha == run["head_sha"]: + break + else: + raise NotFoundError( + "Could not find a run matching the provided settings in the previous hundred runs." + ) + + # Wait for the workflow to finish + url = await wait_for_run(client, run) + + # Filter the artifacts, and return the download URL + artifacts = await client.get(url) + artifacts.raise_for_status() + + for artifact in artifacts.json()["artifacts"]: + if artifact["name"] == artifact_name: + data = await client.get(artifact["archive_download_url"]) + if data.status_code == 302: + return str(data.next_request.url) + + # The following line is left untested since it should in theory be impossible + data.raise_for_status() # pragma: no cover + + raise NotFoundError("Could not find an artifact matching the provided name.") + + finally: + await client.aclose() diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py new file mode 100644 index 00000000..dc17d609 --- /dev/null +++ b/pydis_site/apps/api/tests/test_github_utils.py @@ -0,0 +1,287 @@ +import asyncio +import datetime +import random +import unittest +from unittest import mock + +import django.test +import httpx +import jwt +import rest_framework.response +import rest_framework.test +from django.urls import reverse + +from .. import github_utils + + +def patched_raise_for_status(response: httpx.Response): + """Fake implementation of raise_for_status which does not need a request to be set.""" + if response.status_code // 100 != 2: # pragma: no cover + raise httpx.HTTPStatusError( + f"Non 2xx response code: {response.status_code}", + request=getattr(response, "_request", httpx.Request("GET", "")), + response=response + ) + + +class GeneralUtilityTests(unittest.TestCase): + """Test the utility methods which do not fit in another class.""" + + def test_token_generation(self): + """Test that the a valid JWT token is generated.""" + def encode(payload: dict, _: str, algorithm: str, *args, **kwargs) -> str: + """ + Intercept the encode method. + + It is performed with an algorithm which does not require a PEM key, as it may + not be available in testing environments. + """ + self.assertEqual("RS256", algorithm, "The GitHub App JWT must be signed using RS256.") + return original_encode( + payload, "secret-encoding-key", algorithm="HS256", *args, **kwargs + ) + + original_encode = jwt.encode + with mock.patch("jwt.encode", new=encode): + token = github_utils.generate_token() + decoded = jwt.decode(token, "secret-encoding-key", algorithms=["HS256"]) + + delta = datetime.timedelta(minutes=10) + self.assertAlmostEqual(decoded["exp"] - decoded["iat"], delta.total_seconds()) + self.assertLess(decoded["exp"], (datetime.datetime.now() + delta).timestamp()) + + +@mock.patch("httpx.AsyncClient", autospec=True) +@mock.patch("asyncio.sleep", new=mock.AsyncMock(return_value=asyncio.Future)) +@mock.patch("httpx.Response.raise_for_status", new=patched_raise_for_status) +class WaitForTests(unittest.IsolatedAsyncioTestCase): + """Tests the wait_for utility.""" + + async def test_wait_for_successful_run(self, client_mock: mock.Mock): + """Test that the wait_for method handles successfully runs.""" + final_url = "some_url" + str(random.randint(0, 10)) + + client_mock.get.side_effect = responses = [ + httpx.Response(200, json={"status": "queued", "url": ""}), + httpx.Response(200, json={"status": "pending", "url": ""}), + httpx.Response(200, json={ + "status": "completed", + "conclusion": "success", + "url": "", + "artifacts_url": final_url + }) + ] + + result = await github_utils.wait_for_run(client_mock, responses[0].json()) + self.assertEqual(final_url, result) + + async def test_wait_for_failed_run(self, client_mock: mock.Mock): + """Test that the wait_for method handles failed runs.""" + client_mock.get.return_value = httpx.Response(200, json={ + "status": "completed", + "conclusion": "failed", + }) + + with self.assertRaises(github_utils.ActionFailedError): + await github_utils.wait_for_run(client_mock, {"status": "pending", "url": ""}) + + async def test_wait_for_timeout(self, client_mock: mock.Mock): + """Test that the wait_for method quits after a few attempts.""" + client_mock.get.side_effect = responses = [ + httpx.Response(200, json={"status": "pending", "url": ""}) + ] * (github_utils.MAX_POLLS + 5) + + with self.assertRaises(github_utils.RunTimeoutError): + await github_utils.wait_for_run(client_mock, responses[0].json()) + + +async def get_response_authorize( + _: httpx.AsyncClient, request: httpx.Request, **__ +) -> httpx.Response: + """ + Helper method for the authorize tests. + + Requests are intercepted before being sent out, and the appropriate responses are returned. + """ + path = request.url.path + auth = request.headers.get("Authorization") + + if request.method == "GET": + if path == "/app/installations": + if auth == "bearer JWT initial token": + return httpx.Response(200, request=request, json=[{ + "account": {"login": "VALID_OWNER"}, + "access_tokens_url": "https://example.com/ACCESS_TOKEN_URL" + }]) + else: + return httpx.Response( + 401, json={"error": "auth app/installations"}, request=request + ) + + elif path == "/installation/repositories": + if auth == "bearer app access token": + return httpx.Response(200, request=request, json={ + "repositories": [{ + "name": "VALID_REPO" + }] + }) + else: # pragma: no cover + return httpx.Response( + 401, json={"error": "auth installation/repositories"}, request=request + ) + + elif request.method == "POST": + if path == "/ACCESS_TOKEN_URL": + if auth == "bearer JWT initial token": + return httpx.Response(200, request=request, json={"token": "app access token"}) + else: # pragma: no cover + return httpx.Response(401, json={"error": "auth access_token"}, request=request) + + # Reaching this point means something has gone wrong + return httpx.Response(500, request=request) # pragma: no cover + + +@mock.patch("httpx.AsyncClient.send", new=get_response_authorize) +@mock.patch.object(github_utils, "generate_token", new=mock.Mock(return_value="JWT initial token")) +class AuthorizeTests(unittest.IsolatedAsyncioTestCase): + """Test the authorize utility.""" + + async def test_invalid_apps_auth(self): + """Test that an exception is raised if authorization was attempted with an invalid token.""" + with mock.patch.object(github_utils, "generate_token", return_value="Invalid token"): + with self.assertRaises(httpx.HTTPStatusError) as error: + await github_utils.authorize("VALID_OWNER", "VALID_REPO") + + exception: httpx.HTTPStatusError = error.exception + self.assertEqual(401, exception.response.status_code) + self.assertEqual("auth app/installations", exception.response.json()["error"]) + + async def test_missing_repo(self): + """Test that an exception is raised when the selected owner or repo are not available.""" + with self.assertRaises(github_utils.NotFoundError): + await github_utils.authorize("INVALID_OWNER", "VALID_REPO") + with self.assertRaises(github_utils.NotFoundError): + await github_utils.authorize("VALID_OWNER", "INVALID_REPO") + + async def test_valid_authorization(self): + """Test that an accessible repository can be accessed.""" + client = await github_utils.authorize("VALID_OWNER", "VALID_REPO") + self.assertEqual("bearer app access token", client.headers.get("Authorization")) + + +async def get_response_get_artifact(request: httpx.Request, **_) -> httpx.Response: + """ + Helper method for the get_artifact tests. + + Requests are intercepted before being sent out, and the appropriate responses are returned. + """ + path = request.url.path + + if "force_error" in path: + return httpx.Response(404, request=request) + + if request.method == "GET": + if path == "/repos/owner/repo/actions/runs": + return httpx.Response(200, request=request, json={"workflow_runs": [{ + "name": "action_name", + "head_sha": "action_sha" + }]}) + elif path == "/artifact_url": + return httpx.Response(200, request=request, json={"artifacts": [{ + "name": "artifact_name", + "archive_download_url": "artifact_download_url" + }]}) + elif path == "/artifact_download_url": + response = httpx.Response(302, request=request) + response.next_request = httpx.Request("GET", httpx.URL("https://final_download.url")) + return response + + # Reaching this point means something has gone wrong + return httpx.Response(500, request=request) # pragma: no cover + + +class ArtifactFetcherTests(unittest.IsolatedAsyncioTestCase): + """Test the get_artifact utility.""" + + def setUp(self) -> None: + self.call_args = ["owner", "repo", "action_sha", "action_name", "artifact_name"] + self.client = httpx.AsyncClient(base_url="https://example.com") + + self.patchers = [ + mock.patch.object(self.client, "send", new=get_response_get_artifact), + mock.patch.object(github_utils, "authorize", return_value=self.client), + mock.patch.object(github_utils, "wait_for_run", return_value="artifact_url"), + ] + + for patcher in self.patchers: + patcher.start() + + def tearDown(self) -> None: + for patcher in self.patchers: + patcher.stop() + + def test_client_closed_on_errors(self): + """Test that the client is terminated even if an error occurs at some point.""" + self.call_args[0] = "force_error" + with self.assertRaises(httpx.HTTPStatusError): + github_utils.get_artifact(*self.call_args) + self.assertTrue(self.client.is_closed) + + def test_missing(self): + """Test that an exception is raised if the requested artifact was not found.""" + cases = ( + "invalid sha", + "invalid action name", + "invalid artifact name", + ) + for i, name in enumerate(cases, 2): + with self.subTest(f"Test {name} raises an error"): + new_args = self.call_args.copy() + new_args[i] = name + + with self.assertRaises(github_utils.NotFoundError): + github_utils.get_artifact(*new_args) + + def test_valid(self): + """Test that the correct download URL is returned for valid requests.""" + url = github_utils.get_artifact(*self.call_args) + self.assertEqual("https://final_download.url", url) + self.assertTrue(self.client.is_closed) + + +@mock.patch.object(github_utils, "get_artifact") +class GitHubArtifactViewTests(django.test.TestCase): + """Test the GitHub artifact fetch API view.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.kwargs = { + "owner": "test_owner", + "repo": "test_repo", + "sha": "test_sha", + "action_name": "test_action", + "artifact_name": "test_artifact", + } + cls.url = reverse("api:github-artifacts", kwargs=cls.kwargs) + + async def test_successful(self, artifact_mock: mock.Mock): + """Test a proper response is returned with proper input.""" + artifact_mock.return_value = "final download url" + result = self.client.get(self.url) + + self.assertIsInstance(result, rest_framework.response.Response) + self.assertEqual({"url": artifact_mock.return_value}, result.data) + + async def test_failed_fetch(self, artifact_mock: mock.Mock): + """Test that a proper error is returned when the request fails.""" + artifact_mock.side_effect = github_utils.NotFoundError("Test error message") + result = self.client.get(self.url) + + self.assertIsInstance(result, rest_framework.response.Response) + self.assertEqual({ + "error_type": github_utils.NotFoundError.__name__, + "error": "Test error message", + "requested_resource": "/".join(self.kwargs.values()) + }, result.data) diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index 1e564b29..2757f176 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -1,7 +1,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import HealthcheckView, RulesView +from .views import GitHubArtifactsView, HealthcheckView, RulesView from .viewsets import ( AocAccountLinkViewSet, AocCompletionistBlockViewSet, @@ -86,5 +86,10 @@ urlpatterns = ( # from django_hosts.resolvers import reverse path('bot/', include((bot_router.urls, 'api'), namespace='bot')), path('healthcheck', HealthcheckView.as_view(), name='healthcheck'), - path('rules', RulesView.as_view(), name='rules') + path('rules', RulesView.as_view(), name='rules'), + path( + 'github/artifact/////', + GitHubArtifactsView.as_view(), + name="github-artifacts" + ), ) diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 816463f6..ad2d948e 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -1,7 +1,10 @@ from rest_framework.exceptions import ParseError +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from . import github_utils + class HealthcheckView(APIView): """ @@ -152,3 +155,53 @@ class RulesView(APIView): "Do not offer or ask for paid work of any kind." ), ]) + + +class GitHubArtifactsView(APIView): + """ + Provides utilities for interacting with the GitHub API and obtaining action artifacts. + + ## Routes + ### GET /github/artifacts + Returns a download URL for the artifact requested. + + { + 'url': 'https://pipelines.actions.githubusercontent.com/...' + } + + ### Exceptions + In case of an error, the following body will be returned: + + { + "error_type": "", + "error": "", + "requested_resource": "///" + } + + ## Authentication + Does not require any authentication nor permissions. + """ + + authentication_classes = () + permission_classes = () + + def get( + self, + request: Request, + *, + owner: str, + repo: str, + sha: str, + action_name: str, + artifact_name: str + ) -> Response: + """Return a download URL for the requested artifact.""" + try: + url = github_utils.get_artifact(owner, repo, sha, action_name, artifact_name) + return Response({"url": url}) + except github_utils.ArtifactProcessingError as e: + return Response({ + "error_type": e.__class__.__name__, + "error": str(e), + "requested_resource": f"{owner}/{repo}/{sha}/{action_name}/{artifact_name}" + }, status=e.status) diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 03c16f4b..f382b052 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -21,7 +21,6 @@ import environ import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration - env = environ.Env( DEBUG=(bool, False), SITE_DSN=(str, ""), @@ -30,10 +29,19 @@ env = environ.Env( GIT_SHA=(str, 'development'), TIMEOUT_PERIOD=(int, 5), GITHUB_TOKEN=(str, None), + GITHUB_OAUTH_APP_ID=(str, None), + GITHUB_OAUTH_KEY=(str, None), ) GIT_SHA = env("GIT_SHA") +GITHUB_API = "https://api.github.com" GITHUB_TOKEN = env("GITHUB_TOKEN") +GITHUB_OAUTH_APP_ID = env("GITHUB_OAUTH_APP_ID") +GITHUB_OAUTH_KEY = env("GITHUB_OAUTH_KEY") + +if GITHUB_OAUTH_KEY and (oauth_file := Path(GITHUB_OAUTH_KEY)).is_file(): + # Allow the OAuth key to be loaded from a file + GITHUB_OAUTH_KEY = oauth_file.read_text(encoding="utf-8") sentry_sdk.init( dsn=env('SITE_DSN'), diff --git a/pyproject.toml b/pyproject.toml index 467fc8bc..1c24d308 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ markdown = "~=3.3.4" python-frontmatter = "~=1.0" django-prometheus = "~=2.1" django-distill = "~=2.9.0" +PyJWT = {version = "~=2.4.0", extras = ["crypto"]} [tool.poetry.dev-dependencies] coverage = "~=5.0" -- cgit v1.2.3 From 26a3c19b53883015e8ba87db2a668c3eece2ce20 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 12 Jul 2022 14:45:22 +0400 Subject: Make Awaiting Workflow Run A User Responsibility Moves the responsibility of re-requesting a workflow run from the API to the user. This makes the requests much shorter-lived, and allows the client to control how they want to handle sleeping and retrying. This also has the benefit of removing the only real piece of async code, so now the view is completely sync once again. Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/github_utils.py | 79 +++++------ pydis_site/apps/api/tests/test_github_utils.py | 174 ++++++++++++------------- static-builds/netlify_build.py | 9 +- 3 files changed, 131 insertions(+), 131 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py index 70dccdff..707b36e5 100644 --- a/pydis_site/apps/api/github_utils.py +++ b/pydis_site/apps/api/github_utils.py @@ -1,17 +1,17 @@ """Utilities for working with the GitHub API.""" -import asyncio import datetime import math import httpx import jwt -from asgiref.sync import async_to_sync from pydis_site import settings -MAX_POLLS = 20 -"""The maximum number of attempts at fetching a workflow run.""" +MAX_RUN_TIME = datetime.timedelta(minutes=3) +"""The maximum time allowed before an action is declared timed out.""" +ISO_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ" +"""The datetime string format GitHub uses.""" class ArtifactProcessingError(Exception): @@ -44,6 +44,12 @@ class RunTimeoutError(ArtifactProcessingError): status = 408 +class RunPendingError(ArtifactProcessingError): + """The requested workflow run is still pending, try again later.""" + + status = 202 + + def generate_token() -> str: """ Generate a JWT token to access the GitHub API. @@ -66,7 +72,7 @@ def generate_token() -> str: ) -async def authorize(owner: str, repo: str) -> httpx.AsyncClient: +def authorize(owner: str, repo: str) -> httpx.Client: """ Get an access token for the requested repository. @@ -75,7 +81,7 @@ async def authorize(owner: str, repo: str) -> httpx.AsyncClient: - POST to get a token to access the given app - GET installation/repositories and check if the requested one is part of those """ - client = httpx.AsyncClient( + client = httpx.Client( base_url=settings.GITHUB_API, headers={"Authorization": f"bearer {generate_token()}"}, timeout=settings.TIMEOUT_PERIOD, @@ -83,7 +89,7 @@ async def authorize(owner: str, repo: str) -> httpx.AsyncClient: try: # Get a list of app installations we have access to - apps = await client.get("app/installations") + apps = client.get("app/installations") apps.raise_for_status() for app in apps.json(): @@ -92,11 +98,11 @@ async def authorize(owner: str, repo: str) -> httpx.AsyncClient: continue # Get the repositories of the specified owner - app_token = await client.post(app["access_tokens_url"]) + app_token = client.post(app["access_tokens_url"]) app_token.raise_for_status() client.headers["Authorization"] = f"bearer {app_token.json()['token']}" - repos = await client.get("installation/repositories") + repos = client.get("installation/repositories") repos.raise_for_status() # Search for the request repository @@ -111,44 +117,39 @@ async def authorize(owner: str, repo: str) -> httpx.AsyncClient: except BaseException as e: # Close the client if we encountered an unexpected exception - await client.aclose() + client.close() raise e -async def wait_for_run(client: httpx.AsyncClient, run: dict) -> str: - """Wait for the provided `run` to finish, and return the URL to its artifacts.""" - polls = 0 - while polls <= MAX_POLLS: - if run["status"] != "completed": - # The action is still processing, wait a bit longer - polls += 1 - await asyncio.sleep(10) - - elif run["conclusion"] != "success": - # The action failed, or did not run - raise ActionFailedError(f"The requested workflow ended with: {run['conclusion']}") +def check_run_status(run: dict) -> str: + """Check if the provided run has been completed, otherwise raise an exception.""" + created_at = datetime.datetime.strptime(run["created_at"], ISO_FORMAT_STRING) + run_time = datetime.datetime.now() - created_at + if run["status"] != "completed": + if run_time <= MAX_RUN_TIME: + raise RunPendingError( + f"The requested run is still pending. It was created " + f"{run_time.seconds // 60}:{run_time.seconds % 60 :>02} minutes ago." + ) else: - # The desired action was found, and it ended successfully - return run["artifacts_url"] + raise RunTimeoutError("The requested workflow was not ready in time.") - run = await client.get(run["url"]) - run.raise_for_status() - run = run.json() + if run["conclusion"] != "success": + # The action failed, or did not run + raise ActionFailedError(f"The requested workflow ended with: {run['conclusion']}") - raise RunTimeoutError("The requested workflow was not ready in time.") + # The requested action is ready + return run["artifacts_url"] -@async_to_sync -async def get_artifact( - owner: str, repo: str, sha: str, action_name: str, artifact_name: str -) -> str: +def get_artifact(owner: str, repo: str, sha: str, action_name: str, artifact_name: str) -> str: """Get a download URL for a build artifact.""" - client = await authorize(owner, repo) + client = authorize(owner, repo) try: # Get the workflow runs for this repository - runs = await client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100}) + runs = client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100}) runs.raise_for_status() runs = runs.json() @@ -161,16 +162,16 @@ async def get_artifact( "Could not find a run matching the provided settings in the previous hundred runs." ) - # Wait for the workflow to finish - url = await wait_for_run(client, run) + # Check the workflow status + url = check_run_status(run) # Filter the artifacts, and return the download URL - artifacts = await client.get(url) + artifacts = client.get(url) artifacts.raise_for_status() for artifact in artifacts.json()["artifacts"]: if artifact["name"] == artifact_name: - data = await client.get(artifact["archive_download_url"]) + data = client.get(artifact["archive_download_url"]) if data.status_code == 302: return str(data.next_request.url) @@ -180,4 +181,4 @@ async def get_artifact( raise NotFoundError("Could not find an artifact matching the provided name.") finally: - await client.aclose() + client.close() diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py index dc17d609..78f2979d 100644 --- a/pydis_site/apps/api/tests/test_github_utils.py +++ b/pydis_site/apps/api/tests/test_github_utils.py @@ -1,6 +1,4 @@ -import asyncio import datetime -import random import unittest from unittest import mock @@ -14,16 +12,6 @@ from django.urls import reverse from .. import github_utils -def patched_raise_for_status(response: httpx.Response): - """Fake implementation of raise_for_status which does not need a request to be set.""" - if response.status_code // 100 != 2: # pragma: no cover - raise httpx.HTTPStatusError( - f"Non 2xx response code: {response.status_code}", - request=getattr(response, "_request", httpx.Request("GET", "")), - response=response - ) - - class GeneralUtilityTests(unittest.TestCase): """Test the utility methods which do not fit in another class.""" @@ -51,53 +39,50 @@ class GeneralUtilityTests(unittest.TestCase): self.assertLess(decoded["exp"], (datetime.datetime.now() + delta).timestamp()) -@mock.patch("httpx.AsyncClient", autospec=True) -@mock.patch("asyncio.sleep", new=mock.AsyncMock(return_value=asyncio.Future)) -@mock.patch("httpx.Response.raise_for_status", new=patched_raise_for_status) -class WaitForTests(unittest.IsolatedAsyncioTestCase): - """Tests the wait_for utility.""" - - async def test_wait_for_successful_run(self, client_mock: mock.Mock): - """Test that the wait_for method handles successfully runs.""" - final_url = "some_url" + str(random.randint(0, 10)) - - client_mock.get.side_effect = responses = [ - httpx.Response(200, json={"status": "queued", "url": ""}), - httpx.Response(200, json={"status": "pending", "url": ""}), - httpx.Response(200, json={ - "status": "completed", - "conclusion": "success", - "url": "", - "artifacts_url": final_url - }) - ] +class WaitForTests(unittest.TestCase): + """Tests the check_run_status utility.""" - result = await github_utils.wait_for_run(client_mock, responses[0].json()) - self.assertEqual(final_url, result) + def test_completed_run(self): + final_url = "some_url_string_1234" - async def test_wait_for_failed_run(self, client_mock: mock.Mock): - """Test that the wait_for method handles failed runs.""" - client_mock.get.return_value = httpx.Response(200, json={ + result = github_utils.check_run_status({ "status": "completed", - "conclusion": "failed", + "conclusion": "success", + "created_at": datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING), + "artifacts_url": final_url, }) + self.assertEqual(final_url, result) - with self.assertRaises(github_utils.ActionFailedError): - await github_utils.wait_for_run(client_mock, {"status": "pending", "url": ""}) + def test_pending_run(self): + """Test that a pending run raises the proper exception.""" + with self.assertRaises(github_utils.RunPendingError): + github_utils.check_run_status({ + "status": "pending", + "created_at": datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING), + }) - async def test_wait_for_timeout(self, client_mock: mock.Mock): - """Test that the wait_for method quits after a few attempts.""" - client_mock.get.side_effect = responses = [ - httpx.Response(200, json={"status": "pending", "url": ""}) - ] * (github_utils.MAX_POLLS + 5) + def test_timeout_error(self): + """Test that a timeout is declared after a certain duration.""" + # Set the creation time to well before the MAX_RUN_TIME + # to guarantee the right conclusion + created = ( + datetime.datetime.now() - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10) + ).strftime(github_utils.ISO_FORMAT_STRING) with self.assertRaises(github_utils.RunTimeoutError): - await github_utils.wait_for_run(client_mock, responses[0].json()) + github_utils.check_run_status({"status": "pending", "created_at": created}) + + def test_failed_run(self): + """Test that a failed run raises the proper exception.""" + with self.assertRaises(github_utils.ActionFailedError): + github_utils.check_run_status({ + "status": "completed", + "conclusion": "failed", + "created_at": datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING), + }) -async def get_response_authorize( - _: httpx.AsyncClient, request: httpx.Request, **__ -) -> httpx.Response: +def get_response_authorize(_: httpx.Client, request: httpx.Request, **__) -> httpx.Response: """ Helper method for the authorize tests. @@ -141,76 +126,83 @@ async def get_response_authorize( return httpx.Response(500, request=request) # pragma: no cover -@mock.patch("httpx.AsyncClient.send", new=get_response_authorize) +@mock.patch("httpx.Client.send", new=get_response_authorize) @mock.patch.object(github_utils, "generate_token", new=mock.Mock(return_value="JWT initial token")) -class AuthorizeTests(unittest.IsolatedAsyncioTestCase): +class AuthorizeTests(unittest.TestCase): """Test the authorize utility.""" - async def test_invalid_apps_auth(self): + def test_invalid_apps_auth(self): """Test that an exception is raised if authorization was attempted with an invalid token.""" with mock.patch.object(github_utils, "generate_token", return_value="Invalid token"): with self.assertRaises(httpx.HTTPStatusError) as error: - await github_utils.authorize("VALID_OWNER", "VALID_REPO") + github_utils.authorize("VALID_OWNER", "VALID_REPO") exception: httpx.HTTPStatusError = error.exception self.assertEqual(401, exception.response.status_code) self.assertEqual("auth app/installations", exception.response.json()["error"]) - async def test_missing_repo(self): + def test_missing_repo(self): """Test that an exception is raised when the selected owner or repo are not available.""" with self.assertRaises(github_utils.NotFoundError): - await github_utils.authorize("INVALID_OWNER", "VALID_REPO") + github_utils.authorize("INVALID_OWNER", "VALID_REPO") with self.assertRaises(github_utils.NotFoundError): - await github_utils.authorize("VALID_OWNER", "INVALID_REPO") + github_utils.authorize("VALID_OWNER", "INVALID_REPO") - async def test_valid_authorization(self): + def test_valid_authorization(self): """Test that an accessible repository can be accessed.""" - client = await github_utils.authorize("VALID_OWNER", "VALID_REPO") + client = github_utils.authorize("VALID_OWNER", "VALID_REPO") self.assertEqual("bearer app access token", client.headers.get("Authorization")) -async def get_response_get_artifact(request: httpx.Request, **_) -> httpx.Response: - """ - Helper method for the get_artifact tests. +class ArtifactFetcherTests(unittest.TestCase): + """Test the get_artifact utility.""" - Requests are intercepted before being sent out, and the appropriate responses are returned. - """ - path = request.url.path + @staticmethod + def get_response_get_artifact(request: httpx.Request, **_) -> httpx.Response: + """ + Helper method for the get_artifact tests. - if "force_error" in path: - return httpx.Response(404, request=request) + Requests are intercepted before being sent out, and the appropriate responses are returned. + """ + path = request.url.path - if request.method == "GET": - if path == "/repos/owner/repo/actions/runs": - return httpx.Response(200, request=request, json={"workflow_runs": [{ - "name": "action_name", - "head_sha": "action_sha" - }]}) - elif path == "/artifact_url": - return httpx.Response(200, request=request, json={"artifacts": [{ - "name": "artifact_name", - "archive_download_url": "artifact_download_url" - }]}) - elif path == "/artifact_download_url": - response = httpx.Response(302, request=request) - response.next_request = httpx.Request("GET", httpx.URL("https://final_download.url")) - return response - - # Reaching this point means something has gone wrong - return httpx.Response(500, request=request) # pragma: no cover + if "force_error" in path: + return httpx.Response(404, request=request) + if request.method == "GET": + if path == "/repos/owner/repo/actions/runs": + return httpx.Response( + 200, request=request, json={"workflow_runs": [{ + "name": "action_name", + "head_sha": "action_sha" + }]} + ) + elif path == "/artifact_url": + return httpx.Response( + 200, request=request, json={"artifacts": [{ + "name": "artifact_name", + "archive_download_url": "artifact_download_url" + }]} + ) + elif path == "/artifact_download_url": + response = httpx.Response(302, request=request) + response.next_request = httpx.Request( + "GET", + httpx.URL("https://final_download.url") + ) + return response -class ArtifactFetcherTests(unittest.IsolatedAsyncioTestCase): - """Test the get_artifact utility.""" + # Reaching this point means something has gone wrong + return httpx.Response(500, request=request) # pragma: no cover def setUp(self) -> None: self.call_args = ["owner", "repo", "action_sha", "action_name", "artifact_name"] - self.client = httpx.AsyncClient(base_url="https://example.com") + self.client = httpx.Client(base_url="https://example.com") self.patchers = [ - mock.patch.object(self.client, "send", new=get_response_get_artifact), + mock.patch.object(self.client, "send", new=self.get_response_get_artifact), mock.patch.object(github_utils, "authorize", return_value=self.client), - mock.patch.object(github_utils, "wait_for_run", return_value="artifact_url"), + mock.patch.object(github_utils, "check_run_status", return_value="artifact_url"), ] for patcher in self.patchers: @@ -266,7 +258,7 @@ class GitHubArtifactViewTests(django.test.TestCase): } cls.url = reverse("api:github-artifacts", kwargs=cls.kwargs) - async def test_successful(self, artifact_mock: mock.Mock): + def test_successful(self, artifact_mock: mock.Mock): """Test a proper response is returned with proper input.""" artifact_mock.return_value = "final download url" result = self.client.get(self.url) @@ -274,7 +266,7 @@ class GitHubArtifactViewTests(django.test.TestCase): self.assertIsInstance(result, rest_framework.response.Response) self.assertEqual({"url": artifact_mock.return_value}, result.data) - async def test_failed_fetch(self, artifact_mock: mock.Mock): + def test_failed_fetch(self, artifact_mock: mock.Mock): """Test that a proper error is returned when the request fails.""" artifact_mock.side_effect = github_utils.NotFoundError("Test error message") result = self.client.get(self.url) diff --git a/static-builds/netlify_build.py b/static-builds/netlify_build.py index 13cd0279..a473bd91 100644 --- a/static-builds/netlify_build.py +++ b/static-builds/netlify_build.py @@ -8,6 +8,7 @@ import json import os +import time import zipfile from pathlib import Path from urllib import parse @@ -29,7 +30,7 @@ if __name__ == "__main__": print(f"Fetching download URL from {download_url}") response = httpx.get(download_url, follow_redirects=True) - if response.status_code != 200: + if response.status_code // 100 != 2: try: print(response.json()) except json.JSONDecodeError: @@ -37,6 +38,12 @@ if __name__ == "__main__": response.raise_for_status() + # The workflow is still pending, retry in a bit + while response.status_code == 202: + print(f"{response.json()['error']}. Retrying in 10 seconds.") + time.sleep(10) + response = httpx.get(download_url, follow_redirects=True) + url = response.json()["url"] print(f"Downloading build from {url}") zipped_content = httpx.get(url, follow_redirects=True) -- cgit v1.2.3 From 124c84b9e4f8195485b6ff9b3896cc87e640e02b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Thu, 14 Jul 2022 05:57:05 +0400 Subject: Clean Up Artifact Tests Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/tests/test_github_utils.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py index 78f2979d..a9eab9a5 100644 --- a/pydis_site/apps/api/tests/test_github_utils.py +++ b/pydis_site/apps/api/tests/test_github_utils.py @@ -21,7 +21,7 @@ class GeneralUtilityTests(unittest.TestCase): """ Intercept the encode method. - It is performed with an algorithm which does not require a PEM key, as it may + The result is encoded with an algorithm which does not require a PEM key, as it may not be available in testing environments. """ self.assertEqual("RS256", algorithm, "The GitHub App JWT must be signed using RS256.") @@ -39,10 +39,11 @@ class GeneralUtilityTests(unittest.TestCase): self.assertLess(decoded["exp"], (datetime.datetime.now() + delta).timestamp()) -class WaitForTests(unittest.TestCase): +class CheckRunTests(unittest.TestCase): """Tests the check_run_status utility.""" def test_completed_run(self): + """Test that an already completed run returns the correct URL.""" final_url = "some_url_string_1234" result = github_utils.check_run_status({ @@ -245,20 +246,17 @@ class ArtifactFetcherTests(unittest.TestCase): class GitHubArtifactViewTests(django.test.TestCase): """Test the GitHub artifact fetch API view.""" - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.kwargs = { + def setUp(self): + self.kwargs = { "owner": "test_owner", "repo": "test_repo", "sha": "test_sha", "action_name": "test_action", "artifact_name": "test_artifact", } - cls.url = reverse("api:github-artifacts", kwargs=cls.kwargs) + self.url = reverse("api:github-artifacts", kwargs=self.kwargs) - def test_successful(self, artifact_mock: mock.Mock): + def test_correct_artifact(self, artifact_mock: mock.Mock): """Test a proper response is returned with proper input.""" artifact_mock.return_value = "final download url" result = self.client.get(self.url) -- cgit v1.2.3 From 2d4ba1aa119b6a740ce103d7df39fb0492322252 Mon Sep 17 00:00:00 2001 From: Exenifix <89513380+Exenifix@users.noreply.github.com> Date: Mon, 18 Jul 2022 23:24:03 +0300 Subject: Create docker-hosting-guide.md --- .../guides/python-guides/docker-hosting-guide.md | 194 +++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md new file mode 100644 index 00000000..3ae732e9 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -0,0 +1,194 @@ +## Contents +1. [You will learn](#you-will-learn) +2. [Introduction](#introduction) +3. [Installing Docker](#installing-docker) +4. [Creating Dockerfile](#creating-dockerfile) +5. [Building Image and Running Container](#building-image-and-running-container) +6. [Creating Volumes](#creating-volumes) +7. [Using GitHub Actions for full automation](#using-github-actions-for-full-automation) + +## You will learn +- how to write Dockerfile +- how to build Docker image and run the container +- how to use docker-compose +- how to make docker keep the files throughout the container's runs +- how to parse environment variables into container +- how to use GitHub Actions for automation +- how to setup self hosted runner +- how to use runner secrets + +## Introduction +Let's say you have got a nice discord bot written in python and you have a VPS to host it on. Now the only question is how to run it 24/7. You might have been suggested to use *screen multiplexer*, but it has some disadvantages: +1. Every time you update the bot you have to SSH to your server, attach to screen, shutdown the bot, run `git pull` and run the bot again. You might have good extensions management that allows you to update the bot without restarting it, but there are some other cons as well +2. If you update some dependencies, you have to update them manually +3. The bot doesn't run in an isolated environment, which is not good for security + +But there's a nice and easy solution to these problems - **Docker**! Docker is a containerization utility that automates some stuff like dependencies update and running the application in the background. So let's get started. + +## Installing Docker +The best way to install the docker is to use the [convenience script](https://docs.docker.com/engine/install/ubuntu/#install-using-the-convenience-script) provided by Docker developers themselves. You just need 2 lines: +```shell +$ curl -fsSL https://get.docker.com -o get-docker.sh +$ sudo sh get-docker.sh +``` + +## Creating Dockerfile +To tell Docker what it has to do to run the application, we need to create a file named `Dockerfile` in our project's root. + +1. First we need to specify the *base image*. Doing that will make Docker install some apps we need to run our bot, for example the Python interpreter +```dockerfile +FROM python:3.10-bullseye +``` +2. Next, we need to copy our requirements to some directory *inside the container*. Let's call it `/app` +```dockerfile +COPY requirements.txt /app/ +``` +3. Now we need to set the directory as working and install the requirements +```dockerfile +WORKDIR /app +RUN pip install -r requirements.txt +``` +4. The only thing that is left to do is to copy the rest of project's files and run the main executable +```dockerfile +COPY . . +CMD ["python3", "main.py"] +``` + +The final version of Dockerfile looks like this: +```dockerfile +FROM python:3.10-bullseye +COPY requirements.txt /app/ +WORKDIR /app +RUN pip install -r requirements.txt +COPY . . +CMD ["python3", "main.py"] +``` + +## Building Image and Running Container +Now update the project on your VPS and we can run the bot with Docker. +1. Build the image (dot at the end is very important) +```shell +$ docker build -t mybot . +``` +2. Run the container +```shell +$ docker run -d --name mybot mybot:latest +``` +3. Read bot logs (keep in mind that this utility only allows to read STDERR) +```shell +$ docker logs -f mybot +``` +If everything went successfully, your bot will go online and will keep running! + +## Using docker-compose +Just 2 commands to run a container is cool but we can shorten it down to just 1 simple command. For that, create a `docker-compose.yml` file in project's root and fill it with the following contents: +```yml +version: "3.8" +services: + main: + build: . + container-name: mybot +``` +Update the project on VPS, remove the previous container with `docker rm -f mybot` and run this command +```shell +docker-compose up -d --build +``` +Now the docker will automatically build the image for you and run the container. + +## Creating Volumes +The files creating during container run are destroyed after its recreation. To prevent some files from getting destroyed, we need to use *volumes* that basically save the files from directory inside of container somewhere on drive. +1. Create a new directory somewhere and copy path to it +```shell +$ mkdir mybot-data && echo $(pwd)/mybot-data +``` +My path is `/home/exenifix/mybot-data`, yours is most likely different. +2. In your project, store the files that need to be persistant in a separate directory (eg. `data`) +3. Add the `volumes` construction to `docker-compose` so it looks like this: +```yml +version: "3.8" +services: + main: + build: . + container-name: mybot + volumes: + - /home/exenifix/mybot-data:/app/data +``` +The path before the colon `:` is the directory *on drive* and the second path is the directory *inside of container*. All the files saved in container in that directory will be saved on drive's directory as well and Docker will be accessing them *from drive*. + +## Using GitHub Actions for full automation +Now it's time to fully automate the process and make Docker update the bot automatically on every commit or release. For that, we will use a **GitHub Actions workflow**, which basically runs some commands when we need to. You may read more about them [here](https://docs.github.com/en/actions/using-workflows). + +### Create repository secret +We will not have the ability to use `.env` files with the workflow, so it's better to store the environment variables as **actions secrets**. +1. Head to your repository page -> Settings -> Secrets -> Actions +2. Press `New repository secret` +3. Give it a name like `TOKEN` and paste the value +Now we will be able to access its value in workflow like `${{ secrets.TOKEN }}`. However, we also need to parse the variable into container now. Edit `docker-compose` so it looks like this: +```yml +version: "3.8" +services: + main: + build: . + container-name: mybot + volumes: + - /home/exenifix/mybot-data:/app/data + environment: + - TOKEN +``` + +### Setup self-hosted runner +To run the workflow on our VPS, we will need to register it as *self hosted runner*. +1. Head to Settings -> Actions -> Runners +2. Press `New self-hosted runner` +3. Select runner image and architecture +4. Follow the instructions but don't run the runner +5. Instead, create a service +```shell +$ sudo ./svc.sh install +$ sudo ./svc.sh start +``` +Now we have registered our VPS as a self-hosted runner and we can run the workflow on it now. + +### Write a workflow +Create a new file `.github/workflows/runner.yml` and paste the following content into it (it is easy to understand so I am not going to give many comments) +```yml +name: Docker Runner + +on: + push: + branches: [ master ] + +jobs: + run: + runs-on: self-hosted + environment: production + + steps: + - uses: actions/checkout@v3 + + - name: Run Container + run: docker-compose up -d --build + env: + TOKEN: ${{ secrets.TOKEN }} + + - name: Cleanup Unused Images + run: docker image prune -f +``` + +Run `docker rm -f mybot` (it only needs to be done once) and push to GitHub. Now if you open `Actions` tab on your repository, you should see a workflow running your bot. Congratulations! + +### Displaying logs in actions terminal +There's a nice utility for reading docker container's logs and stopping upon meeting a certain phrase and it might be useful for you as well. +1. Install the utility on your VPS with +```shell +$ pip install exendlr +``` +2. Add a step to your workflow that would show the logs until it meets `"ready"` phrase. I recommend putting it before the cleanup. +```yml +- name: Display Logs + run: python3 -m exendlr mybot "ready" +``` +Now you should see the logs of your bot until the stop phrase is met. + +**WARNING** +> The utility only reads from STDERR and redirects to STDERR, if you are using STDOUT for logs, it will not work and will be waiting for stop phrase forever. The utility automatically exits if bot's container is stopped (eg. error occured during starting) or if a log line contains a stop phrase. Make sure that your bot 100% displays a stop phrase when it's ready otherwise your workflow will get stuck. -- cgit v1.2.3 From 2f7aecada0165428017b24baf03ba0a95049a932 Mon Sep 17 00:00:00 2001 From: Exenifix Date: Mon, 18 Jul 2022 23:34:51 +0300 Subject: Updated a docker guide --- .../guides/python-guides/docker-hosting-guide.md | 123 +++++++++++++++++---- 1 file changed, 101 insertions(+), 22 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md index 3ae732e9..b6735586 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -1,4 +1,10 @@ +--- +title: How to host a bot with Docker and GitHub Actions on Ubuntu VPS +description: This guide shows how to host a bot with Docker and GitHub Actions on Ubuntu VPS +--- + ## Contents + 1. [You will learn](#you-will-learn) 2. [Introduction](#introduction) 3. [Installing Docker](#installing-docker) @@ -8,53 +14,75 @@ 7. [Using GitHub Actions for full automation](#using-github-actions-for-full-automation) ## You will learn + - how to write Dockerfile - how to build Docker image and run the container - how to use docker-compose - how to make docker keep the files throughout the container's runs - how to parse environment variables into container - how to use GitHub Actions for automation -- how to setup self hosted runner +- how to setup self-hosted runner - how to use runner secrets ## Introduction -Let's say you have got a nice discord bot written in python and you have a VPS to host it on. Now the only question is how to run it 24/7. You might have been suggested to use *screen multiplexer*, but it has some disadvantages: -1. Every time you update the bot you have to SSH to your server, attach to screen, shutdown the bot, run `git pull` and run the bot again. You might have good extensions management that allows you to update the bot without restarting it, but there are some other cons as well + +Let's say you have got a nice discord bot written in python and you have a VPS to host it on. Now the only question is +how to run it 24/7. You might have been suggested to use *screen multiplexer*, but it has some disadvantages: + +1. Every time you update the bot you have to SSH to your server, attach to screen, shutdown the bot, run `git pull` and + run the bot again. You might have good extensions management that allows you to update the bot without restarting it, + but there are some other cons as well 2. If you update some dependencies, you have to update them manually 3. The bot doesn't run in an isolated environment, which is not good for security -But there's a nice and easy solution to these problems - **Docker**! Docker is a containerization utility that automates some stuff like dependencies update and running the application in the background. So let's get started. +But there's a nice and easy solution to these problems - **Docker**! Docker is a containerization utility that automates +some stuff like dependencies update and running the application in the background. So let's get started. ## Installing Docker -The best way to install the docker is to use the [convenience script](https://docs.docker.com/engine/install/ubuntu/#install-using-the-convenience-script) provided by Docker developers themselves. You just need 2 lines: + +The best way to install the docker is to use +the [convenience script](https://docs.docker.com/engine/install/ubuntu/#install-using-the-convenience-script) provided +by Docker developers themselves. You just need 2 lines: + ```shell $ curl -fsSL https://get.docker.com -o get-docker.sh $ sudo sh get-docker.sh ``` ## Creating Dockerfile -To tell Docker what it has to do to run the application, we need to create a file named `Dockerfile` in our project's root. -1. First we need to specify the *base image*. Doing that will make Docker install some apps we need to run our bot, for example the Python interpreter +To tell Docker what it has to do to run the application, we need to create a file named `Dockerfile` in our project's +root. + +1. First we need to specify the *base image*. Doing that will make Docker install some apps we need to run our bot, for + example the Python interpreter + ```dockerfile FROM python:3.10-bullseye ``` + 2. Next, we need to copy our requirements to some directory *inside the container*. Let's call it `/app` + ```dockerfile COPY requirements.txt /app/ ``` + 3. Now we need to set the directory as working and install the requirements + ```dockerfile WORKDIR /app RUN pip install -r requirements.txt ``` + 4. The only thing that is left to do is to copy the rest of project's files and run the main executable + ```dockerfile COPY . . CMD ["python3", "main.py"] ``` The final version of Dockerfile looks like this: + ```dockerfile FROM python:3.10-bullseye COPY requirements.txt /app/ @@ -65,71 +93,103 @@ CMD ["python3", "main.py"] ``` ## Building Image and Running Container + Now update the project on your VPS and we can run the bot with Docker. + 1. Build the image (dot at the end is very important) + ```shell $ docker build -t mybot . ``` + 2. Run the container + ```shell $ docker run -d --name mybot mybot:latest ``` + 3. Read bot logs (keep in mind that this utility only allows to read STDERR) + ```shell $ docker logs -f mybot ``` + If everything went successfully, your bot will go online and will keep running! ## Using docker-compose -Just 2 commands to run a container is cool but we can shorten it down to just 1 simple command. For that, create a `docker-compose.yml` file in project's root and fill it with the following contents: + +Just 2 commands to run a container is cool but we can shorten it down to just 1 simple command. For that, create +a `docker-compose.yml` file in project's root and fill it with the following contents: + ```yml version: "3.8" services: main: build: . - container-name: mybot + container_name: mybot ``` + Update the project on VPS, remove the previous container with `docker rm -f mybot` and run this command + ```shell docker-compose up -d --build ``` + Now the docker will automatically build the image for you and run the container. ## Creating Volumes -The files creating during container run are destroyed after its recreation. To prevent some files from getting destroyed, we need to use *volumes* that basically save the files from directory inside of container somewhere on drive. + +The files creating during container run are destroyed after its recreation. To prevent some files from getting +destroyed, we need to use *volumes* that basically save the files from directory inside of container somewhere on drive. + 1. Create a new directory somewhere and copy path to it + ```shell $ mkdir mybot-data && echo $(pwd)/mybot-data ``` + My path is `/home/exenifix/mybot-data`, yours is most likely different. + 2. In your project, store the files that need to be persistant in a separate directory (eg. `data`) 3. Add the `volumes` construction to `docker-compose` so it looks like this: + ```yml version: "3.8" services: main: build: . - container-name: mybot + container_name: mybot volumes: - /home/exenifix/mybot-data:/app/data ``` -The path before the colon `:` is the directory *on drive* and the second path is the directory *inside of container*. All the files saved in container in that directory will be saved on drive's directory as well and Docker will be accessing them *from drive*. + +The path before the colon `:` is the directory *on drive* and the second path is the directory *inside of container*. +All the files saved in container in that directory will be saved on drive's directory as well and Docker will be +accessing them *from drive*. ## Using GitHub Actions for full automation -Now it's time to fully automate the process and make Docker update the bot automatically on every commit or release. For that, we will use a **GitHub Actions workflow**, which basically runs some commands when we need to. You may read more about them [here](https://docs.github.com/en/actions/using-workflows). + +Now it's time to fully automate the process and make Docker update the bot automatically on every commit or release. For +that, we will use a **GitHub Actions workflow**, which basically runs some commands when we need to. You may read more +about them [here](https://docs.github.com/en/actions/using-workflows). ### Create repository secret -We will not have the ability to use `.env` files with the workflow, so it's better to store the environment variables as **actions secrets**. + +We will not have the ability to use `.env` files with the workflow, so it's better to store the environment variables +as **actions secrets**. + 1. Head to your repository page -> Settings -> Secrets -> Actions 2. Press `New repository secret` 3. Give it a name like `TOKEN` and paste the value -Now we will be able to access its value in workflow like `${{ secrets.TOKEN }}`. However, we also need to parse the variable into container now. Edit `docker-compose` so it looks like this: + Now we will be able to access its value in workflow like `${{ secrets.TOKEN }}`. However, we also need to parse the + variable into container now. Edit `docker-compose` so it looks like this: + ```yml version: "3.8" services: main: build: . - container-name: mybot + container_name: mybot volumes: - /home/exenifix/mybot-data:/app/data environment: @@ -137,20 +197,27 @@ services: ``` ### Setup self-hosted runner + To run the workflow on our VPS, we will need to register it as *self hosted runner*. + 1. Head to Settings -> Actions -> Runners 2. Press `New self-hosted runner` 3. Select runner image and architecture 4. Follow the instructions but don't run the runner 5. Instead, create a service + ```shell $ sudo ./svc.sh install $ sudo ./svc.sh start ``` + Now we have registered our VPS as a self-hosted runner and we can run the workflow on it now. ### Write a workflow -Create a new file `.github/workflows/runner.yml` and paste the following content into it (it is easy to understand so I am not going to give many comments) + +Create a new file `.github/workflows/runner.yml` and paste the following content into it (it is easy to understand so I +am not going to give many comments) + ```yml name: Docker Runner @@ -175,20 +242,32 @@ jobs: run: docker image prune -f ``` -Run `docker rm -f mybot` (it only needs to be done once) and push to GitHub. Now if you open `Actions` tab on your repository, you should see a workflow running your bot. Congratulations! +Run `docker rm -f mybot` (it only needs to be done once) and push to GitHub. Now if you open `Actions` tab on your +repository, you should see a workflow running your bot. Congratulations! ### Displaying logs in actions terminal -There's a nice utility for reading docker container's logs and stopping upon meeting a certain phrase and it might be useful for you as well. + +There's a nice utility for reading docker container's logs and stopping upon meeting a certain phrase and it might be +useful for you as well. + 1. Install the utility on your VPS with + ```shell $ pip install exendlr ``` -2. Add a step to your workflow that would show the logs until it meets `"ready"` phrase. I recommend putting it before the cleanup. + +2. Add a step to your workflow that would show the logs until it meets `"ready"` phrase. I recommend putting it before + the cleanup. + ```yml - name: Display Logs run: python3 -m exendlr mybot "ready" ``` -Now you should see the logs of your bot until the stop phrase is met. + +Now you should see the logs of your bot until the stop phrase is met. **WARNING** -> The utility only reads from STDERR and redirects to STDERR, if you are using STDOUT for logs, it will not work and will be waiting for stop phrase forever. The utility automatically exits if bot's container is stopped (eg. error occured during starting) or if a log line contains a stop phrase. Make sure that your bot 100% displays a stop phrase when it's ready otherwise your workflow will get stuck. +> The utility only reads from STDERR and redirects to STDERR, if you are using STDOUT for logs, it will not work and +> will be waiting for stop phrase forever. The utility automatically exits if bot's container is stopped (eg. error +> occured during starting) or if a log line contains a stop phrase. Make sure that your bot 100% displays a stop phrase +> when it's ready otherwise your workflow will get stuck. -- cgit v1.2.3 From 37001bca59c1d3d5fc8a8dadffda00d55fc9e0b6 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sun, 24 Jul 2022 08:32:43 +0200 Subject: Use Dataclass For Workflow Run Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/github_utils.py | 41 ++++++++++++++++----- pydis_site/apps/api/tests/test_github_utils.py | 50 +++++++++++++++----------- 2 files changed, 62 insertions(+), 29 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py index 707b36e5..c4ace6b7 100644 --- a/pydis_site/apps/api/github_utils.py +++ b/pydis_site/apps/api/github_utils.py @@ -1,7 +1,8 @@ """Utilities for working with the GitHub API.""" - +import dataclasses import datetime import math +import typing import httpx import jwt @@ -50,6 +51,29 @@ class RunPendingError(ArtifactProcessingError): status = 202 +@dataclasses.dataclass(frozen=True) +class WorkflowRun: + """ + A workflow run from the GitHub API. + + https://docs.github.com/en/rest/actions/workflow-runs#get-a-workflow-run + """ + + name: str + head_sha: str + created_at: str + status: str + conclusion: str + artifacts_url: str + + @classmethod + def from_raw(cls, data: dict[str, typing.Any]): + """Create an instance using the raw data from the API, discarding unused fields.""" + return cls(**{ + key.name: data[key.name] for key in dataclasses.fields(cls) + }) + + def generate_token() -> str: """ Generate a JWT token to access the GitHub API. @@ -121,12 +145,12 @@ def authorize(owner: str, repo: str) -> httpx.Client: raise e -def check_run_status(run: dict) -> str: +def check_run_status(run: WorkflowRun) -> str: """Check if the provided run has been completed, otherwise raise an exception.""" - created_at = datetime.datetime.strptime(run["created_at"], ISO_FORMAT_STRING) + created_at = datetime.datetime.strptime(run.created_at, ISO_FORMAT_STRING) run_time = datetime.datetime.now() - created_at - if run["status"] != "completed": + if run.status != "completed": if run_time <= MAX_RUN_TIME: raise RunPendingError( f"The requested run is still pending. It was created " @@ -135,12 +159,12 @@ def check_run_status(run: dict) -> str: else: raise RunTimeoutError("The requested workflow was not ready in time.") - if run["conclusion"] != "success": + if run.conclusion != "success": # The action failed, or did not run - raise ActionFailedError(f"The requested workflow ended with: {run['conclusion']}") + raise ActionFailedError(f"The requested workflow ended with: {run.conclusion}") # The requested action is ready - return run["artifacts_url"] + return run.artifacts_url def get_artifact(owner: str, repo: str, sha: str, action_name: str, artifact_name: str) -> str: @@ -155,7 +179,8 @@ def get_artifact(owner: str, repo: str, sha: str, action_name: str, artifact_nam # Filter the runs for the one associated with the given SHA for run in runs["workflow_runs"]: - if run["name"] == action_name and sha == run["head_sha"]: + run = WorkflowRun.from_raw(run) + if run.name == action_name and sha == run.head_sha: break else: raise NotFoundError( diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py index a9eab9a5..f5e072a9 100644 --- a/pydis_site/apps/api/tests/test_github_utils.py +++ b/pydis_site/apps/api/tests/test_github_utils.py @@ -1,4 +1,6 @@ +import dataclasses import datetime +import typing import unittest from unittest import mock @@ -42,45 +44,46 @@ class GeneralUtilityTests(unittest.TestCase): class CheckRunTests(unittest.TestCase): """Tests the check_run_status utility.""" + run_kwargs: typing.Mapping = { + "name": "run_name", + "head_sha": "sha", + "status": "completed", + "conclusion": "success", + "created_at": datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING), + "artifacts_url": "url", + } + def test_completed_run(self): """Test that an already completed run returns the correct URL.""" final_url = "some_url_string_1234" - result = github_utils.check_run_status({ - "status": "completed", - "conclusion": "success", - "created_at": datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING), - "artifacts_url": final_url, - }) + kwargs = dict(self.run_kwargs, artifacts_url=final_url) + result = github_utils.check_run_status(github_utils.WorkflowRun(**kwargs)) self.assertEqual(final_url, result) def test_pending_run(self): """Test that a pending run raises the proper exception.""" + kwargs = dict(self.run_kwargs, status="pending") with self.assertRaises(github_utils.RunPendingError): - github_utils.check_run_status({ - "status": "pending", - "created_at": datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING), - }) + github_utils.check_run_status(github_utils.WorkflowRun(**kwargs)) def test_timeout_error(self): """Test that a timeout is declared after a certain duration.""" + kwargs = dict(self.run_kwargs, status="pending") # Set the creation time to well before the MAX_RUN_TIME # to guarantee the right conclusion - created = ( + kwargs["created_at"] = ( datetime.datetime.now() - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10) ).strftime(github_utils.ISO_FORMAT_STRING) with self.assertRaises(github_utils.RunTimeoutError): - github_utils.check_run_status({"status": "pending", "created_at": created}) + github_utils.check_run_status(github_utils.WorkflowRun(**kwargs)) def test_failed_run(self): """Test that a failed run raises the proper exception.""" + kwargs = dict(self.run_kwargs, conclusion="failed") with self.assertRaises(github_utils.ActionFailedError): - github_utils.check_run_status({ - "status": "completed", - "conclusion": "failed", - "created_at": datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING), - }) + github_utils.check_run_status(github_utils.WorkflowRun(**kwargs)) def get_response_authorize(_: httpx.Client, request: httpx.Request, **__) -> httpx.Response: @@ -172,11 +175,16 @@ class ArtifactFetcherTests(unittest.TestCase): if request.method == "GET": if path == "/repos/owner/repo/actions/runs": + run = github_utils.WorkflowRun( + name="action_name", + head_sha="action_sha", + created_at=datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING), + status="completed", + conclusion="success", + artifacts_url="artifacts_url" + ) return httpx.Response( - 200, request=request, json={"workflow_runs": [{ - "name": "action_name", - "head_sha": "action_sha" - }]} + 200, request=request, json={"workflow_runs": [dataclasses.asdict(run)]} ) elif path == "/artifact_url": return httpx.Response( -- cgit v1.2.3 From f16d3b1b1d14cdf0de1e56ae2bc466152e930f34 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sun, 24 Jul 2022 10:06:47 +0200 Subject: Use UTC Time For GitHub API When reading the created_at time from the GitHub API, it'll be a naive date string with UTC time, so we use that instead of the system's time. Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/github_utils.py | 2 +- pydis_site/apps/api/tests/test_github_utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py index c4ace6b7..7d26b147 100644 --- a/pydis_site/apps/api/github_utils.py +++ b/pydis_site/apps/api/github_utils.py @@ -148,7 +148,7 @@ def authorize(owner: str, repo: str) -> httpx.Client: def check_run_status(run: WorkflowRun) -> str: """Check if the provided run has been completed, otherwise raise an exception.""" created_at = datetime.datetime.strptime(run.created_at, ISO_FORMAT_STRING) - run_time = datetime.datetime.now() - created_at + run_time = datetime.datetime.utcnow() - created_at if run.status != "completed": if run_time <= MAX_RUN_TIME: diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py index f5e072a9..f642f689 100644 --- a/pydis_site/apps/api/tests/test_github_utils.py +++ b/pydis_site/apps/api/tests/test_github_utils.py @@ -49,7 +49,7 @@ class CheckRunTests(unittest.TestCase): "head_sha": "sha", "status": "completed", "conclusion": "success", - "created_at": datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING), + "created_at": datetime.datetime.utcnow().strftime(github_utils.ISO_FORMAT_STRING), "artifacts_url": "url", } @@ -73,7 +73,7 @@ class CheckRunTests(unittest.TestCase): # Set the creation time to well before the MAX_RUN_TIME # to guarantee the right conclusion kwargs["created_at"] = ( - datetime.datetime.now() - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10) + datetime.datetime.utcnow() - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10) ).strftime(github_utils.ISO_FORMAT_STRING) with self.assertRaises(github_utils.RunTimeoutError): -- cgit v1.2.3 From ab6c82e5f6f6681fd73daeabfb8c4019ef3eb086 Mon Sep 17 00:00:00 2001 From: Exenifix <89513380+Exenifix@users.noreply.github.com> Date: Sun, 24 Jul 2022 11:27:32 +0300 Subject: Additional explanation about docker base image Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/docker-hosting-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md index b6735586..c03ae68e 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -54,7 +54,7 @@ $ sudo sh get-docker.sh To tell Docker what it has to do to run the application, we need to create a file named `Dockerfile` in our project's root. -1. First we need to specify the *base image*. Doing that will make Docker install some apps we need to run our bot, for +1. First we need to specify the *base image*, which is the OS that the docker container will be running. Doing that will make Docker install some apps we need to run our bot, for example the Python interpreter ```dockerfile -- cgit v1.2.3 From ff10aa547c2e3589801c73f6898a808dd1688718 Mon Sep 17 00:00:00 2001 From: Exenifix <89513380+Exenifix@users.noreply.github.com> Date: Sun, 24 Jul 2022 11:28:10 +0300 Subject: Changed "requirements" to external dependencies Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com> --- .../apps/content/resources/guides/python-guides/docker-hosting-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md index c03ae68e..5e2e40a3 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -61,7 +61,7 @@ root. FROM python:3.10-bullseye ``` -2. Next, we need to copy our requirements to some directory *inside the container*. Let's call it `/app` +2. Next, we need to copy our Python project's external dependencies to some directory *inside the container*. Let's call it `/app` ```dockerfile COPY requirements.txt /app/ -- cgit v1.2.3 From 13886286414b3603d423f054b91a51cb5f0029d2 Mon Sep 17 00:00:00 2001 From: Exenifix <89513380+Exenifix@users.noreply.github.com> Date: Sun, 24 Jul 2022 12:05:11 +0300 Subject: Removed unnecessary combination Co-authored-by: Robin <74519799+Robin5605@users.noreply.github.com> --- .../content/resources/guides/python-guides/docker-hosting-guide.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md index 5e2e40a3..67542f20 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -145,7 +145,8 @@ destroyed, we need to use *volumes* that basically save the files from directory 1. Create a new directory somewhere and copy path to it ```shell -$ mkdir mybot-data && echo $(pwd)/mybot-data +$ mkdir mybot-data +$ echo $(pwd)/mybot-data ``` My path is `/home/exenifix/mybot-data`, yours is most likely different. -- cgit v1.2.3 From fa7143f04da204cbeedb76e269e2f527e1cbb4e8 Mon Sep 17 00:00:00 2001 From: Exenifix Date: Sun, 24 Jul 2022 12:09:14 +0300 Subject: Updated the guide as requested --- .../guides/python-guides/docker-hosting-guide.md | 35 +++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md index 67542f20..a42d11c1 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -102,18 +102,29 @@ Now update the project on your VPS and we can run the bot with Docker. $ docker build -t mybot . ``` +- the `-t` flag specifies a **tag** that will be assigned to the image. With it, we can easily run the image that the tag was assigned to. +- the dot at the end is basically the path to search for Dockerfile. The dot means current directory (`./`) + 2. Run the container ```shell $ docker run -d --name mybot mybot:latest ``` +- `-d` flag tells Docker to run the container in detached mode, meaning it will run the container but will not give us any output from it. If we don't +provide it, the `run` will be giving us the output until the application exits. Discord bots aren't supposed to exit after certain time, so we do need this flag +- `--name` assigns a name to the container. By default, container is identified by id that is not human-readable. To conveniently refer to container when needed, +we can assign it a name +- `mybot:latest` means "latest version of `mybot` image" + 3. Read bot logs (keep in mind that this utility only allows to read STDERR) ```shell $ docker logs -f mybot ``` +- `-f` flag tells the docker to keep reading logs as they appear in container and is called "follow mode". To exit press `CTRL + C`. + If everything went successfully, your bot will go online and will keep running! ## Using docker-compose @@ -129,6 +140,12 @@ services: container_name: mybot ``` +- `version` tells Docker what version of `docker-compose` to use. You may check all the versions [here](https://docs.docker.com/compose/compose-file/compose-versioning/) +- `services` contains services to build and run. Read more about services [here](https://docs.docker.com/compose/compose-file/#services-top-level-element) +- `main` is a service. We can call it whatever we would like to, not necessarily `main` +- `build: .` is a path to search from Dockerfile, just like `docker build` command's dot +- `container_name: mybot` is a container name to use for a bot, just like `docker run --name mybot` + Update the project on VPS, remove the previous container with `docker rm -f mybot` and run this command ```shell @@ -137,6 +154,16 @@ docker-compose up -d --build Now the docker will automatically build the image for you and run the container. +### Why docker-compose +The main purpose of `docker-compose` is mostly to allow running several images at once within one container. Mostly we don't need this in discord bots. +For us, it has the following benefits: +- we can build and run the container just with one command +- if we need to parse some environment variables or volumes (more about them further in tutorial) our run command would look like this +```shell +$ docker run -d --name mybot -e TOKEN=... -e WEATHER_API_APIKEY=... -e SOME_USEFUL_ENVIRONMENT_VARIABLE=... --net=host -v /home/exenifix/bot-data/data:/app/data -v /home/exenifix/bot-data/images:/app/data/images +``` +This is pretty long and unreadable. `docker-compose` allows us to transfer those flags into single config file and still use just one short command to run the container. + ## Creating Volumes The files creating during container run are destroyed after its recreation. To prevent some files from getting @@ -149,7 +176,7 @@ $ mkdir mybot-data $ echo $(pwd)/mybot-data ``` -My path is `/home/exenifix/mybot-data`, yours is most likely different. +My path is `/home/exenifix/mybot-data`, yours is most likely **different**! 2. In your project, store the files that need to be persistant in a separate directory (eg. `data`) 3. Add the `volumes` construction to `docker-compose` so it looks like this: @@ -164,7 +191,7 @@ services: - /home/exenifix/mybot-data:/app/data ``` -The path before the colon `:` is the directory *on drive* and the second path is the directory *inside of container*. +The path before the colon `:` is the directory *on server's drive, outside of container*, and the second path is the directory *inside of container*. All the files saved in container in that directory will be saved on drive's directory as well and Docker will be accessing them *from drive*. @@ -177,11 +204,11 @@ about them [here](https://docs.github.com/en/actions/using-workflows). ### Create repository secret We will not have the ability to use `.env` files with the workflow, so it's better to store the environment variables -as **actions secrets**. +as **actions secrets**. Let's add your discord bot's token as a secret 1. Head to your repository page -> Settings -> Secrets -> Actions 2. Press `New repository secret` -3. Give it a name like `TOKEN` and paste the value +3. Give it a name like `TOKEN` and paste the token Now we will be able to access its value in workflow like `${{ secrets.TOKEN }}`. However, we also need to parse the variable into container now. Edit `docker-compose` so it looks like this: -- cgit v1.2.3 From 2fbc05dd55f20a92ec4e2e43bfbb2e653f24f552 Mon Sep 17 00:00:00 2001 From: Exenifix Date: Sun, 24 Jul 2022 12:10:41 +0300 Subject: Guide linting applied --- .../guides/python-guides/docker-hosting-guide.md | 45 +++++++++++++++------- 1 file changed, 31 insertions(+), 14 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md index a42d11c1..5fb55caf 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -54,14 +54,16 @@ $ sudo sh get-docker.sh To tell Docker what it has to do to run the application, we need to create a file named `Dockerfile` in our project's root. -1. First we need to specify the *base image*, which is the OS that the docker container will be running. Doing that will make Docker install some apps we need to run our bot, for +1. First we need to specify the *base image*, which is the OS that the docker container will be running. Doing that will + make Docker install some apps we need to run our bot, for example the Python interpreter ```dockerfile FROM python:3.10-bullseye ``` -2. Next, we need to copy our Python project's external dependencies to some directory *inside the container*. Let's call it `/app` +2. Next, we need to copy our Python project's external dependencies to some directory *inside the container*. Let's call + it `/app` ```dockerfile COPY requirements.txt /app/ @@ -102,7 +104,8 @@ Now update the project on your VPS and we can run the bot with Docker. $ docker build -t mybot . ``` -- the `-t` flag specifies a **tag** that will be assigned to the image. With it, we can easily run the image that the tag was assigned to. +- the `-t` flag specifies a **tag** that will be assigned to the image. With it, we can easily run the image that the + tag was assigned to. - the dot at the end is basically the path to search for Dockerfile. The dot means current directory (`./`) 2. Run the container @@ -111,10 +114,13 @@ $ docker build -t mybot . $ docker run -d --name mybot mybot:latest ``` -- `-d` flag tells Docker to run the container in detached mode, meaning it will run the container but will not give us any output from it. If we don't -provide it, the `run` will be giving us the output until the application exits. Discord bots aren't supposed to exit after certain time, so we do need this flag -- `--name` assigns a name to the container. By default, container is identified by id that is not human-readable. To conveniently refer to container when needed, -we can assign it a name +- `-d` flag tells Docker to run the container in detached mode, meaning it will run the container but will not give us + any output from it. If we don't + provide it, the `run` will be giving us the output until the application exits. Discord bots aren't supposed to exit + after certain time, so we do need this flag +- `--name` assigns a name to the container. By default, container is identified by id that is not human-readable. To + conveniently refer to container when needed, + we can assign it a name - `mybot:latest` means "latest version of `mybot` image" 3. Read bot logs (keep in mind that this utility only allows to read STDERR) @@ -123,7 +129,8 @@ we can assign it a name $ docker logs -f mybot ``` -- `-f` flag tells the docker to keep reading logs as they appear in container and is called "follow mode". To exit press `CTRL + C`. +- `-f` flag tells the docker to keep reading logs as they appear in container and is called "follow mode". To exit + press `CTRL + C`. If everything went successfully, your bot will go online and will keep running! @@ -140,8 +147,10 @@ services: container_name: mybot ``` -- `version` tells Docker what version of `docker-compose` to use. You may check all the versions [here](https://docs.docker.com/compose/compose-file/compose-versioning/) -- `services` contains services to build and run. Read more about services [here](https://docs.docker.com/compose/compose-file/#services-top-level-element) +- `version` tells Docker what version of `docker-compose` to use. You may check all the + versions [here](https://docs.docker.com/compose/compose-file/compose-versioning/) +- `services` contains services to build and run. Read more about + services [here](https://docs.docker.com/compose/compose-file/#services-top-level-element) - `main` is a service. We can call it whatever we would like to, not necessarily `main` - `build: .` is a path to search from Dockerfile, just like `docker build` command's dot - `container_name: mybot` is a container name to use for a bot, just like `docker run --name mybot` @@ -155,14 +164,21 @@ docker-compose up -d --build Now the docker will automatically build the image for you and run the container. ### Why docker-compose -The main purpose of `docker-compose` is mostly to allow running several images at once within one container. Mostly we don't need this in discord bots. + +The main purpose of `docker-compose` is mostly to allow running several images at once within one container. Mostly we +don't need this in discord bots. For us, it has the following benefits: + - we can build and run the container just with one command -- if we need to parse some environment variables or volumes (more about them further in tutorial) our run command would look like this +- if we need to parse some environment variables or volumes (more about them further in tutorial) our run command would + look like this + ```shell $ docker run -d --name mybot -e TOKEN=... -e WEATHER_API_APIKEY=... -e SOME_USEFUL_ENVIRONMENT_VARIABLE=... --net=host -v /home/exenifix/bot-data/data:/app/data -v /home/exenifix/bot-data/images:/app/data/images ``` -This is pretty long and unreadable. `docker-compose` allows us to transfer those flags into single config file and still use just one short command to run the container. + +This is pretty long and unreadable. `docker-compose` allows us to transfer those flags into single config file and still +use just one short command to run the container. ## Creating Volumes @@ -191,7 +207,8 @@ services: - /home/exenifix/mybot-data:/app/data ``` -The path before the colon `:` is the directory *on server's drive, outside of container*, and the second path is the directory *inside of container*. +The path before the colon `:` is the directory *on server's drive, outside of container*, and the second path is the +directory *inside of container*. All the files saved in container in that directory will be saved on drive's directory as well and Docker will be accessing them *from drive*. -- cgit v1.2.3 From dfc32e28103d652170868d09b49ba98ea95c91bf Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 27 Jul 2022 21:33:13 +0100 Subject: Add a field to track the time an infraction was last applied A default is set for backwards compatibility with bot version that don't explicitly give a value. --- .../api/migrations/0084_infraction_last_applied.py | 19 +++++++++++++++++++ pydis_site/apps/api/models/bot/infraction.py | 6 ++++++ 2 files changed, 25 insertions(+) create mode 100644 pydis_site/apps/api/migrations/0084_infraction_last_applied.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0084_infraction_last_applied.py b/pydis_site/apps/api/migrations/0084_infraction_last_applied.py new file mode 100644 index 00000000..0977fa20 --- /dev/null +++ b/pydis_site/apps/api/migrations/0084_infraction_last_applied.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.6 on 2022-07-27 20:32 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0083_remove_embed_validation'), + ] + + operations = [ + migrations.AddField( + model_name='infraction', + name='last_applied', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='The date and time of when this infraction was last applied.'), + ), + ] diff --git a/pydis_site/apps/api/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py index c9303024..218ee5ec 100644 --- a/pydis_site/apps/api/models/bot/infraction.py +++ b/pydis_site/apps/api/models/bot/infraction.py @@ -23,6 +23,12 @@ class Infraction(ModelReprMixin, models.Model): default=timezone.now, help_text="The date and time of the creation of this infraction." ) + last_applied = models.DateTimeField( + # This default is for backwards compatibility with bot versions + # that don't explicitly give a value. + default=timezone.now, + help_text="The date and time of when this infraction was last applied." + ) expires_at = models.DateTimeField( null=True, help_text=( -- cgit v1.2.3 From 163201d27fa7505632a36a3d918ebb9321856554 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 27 Jul 2022 22:31:09 +0100 Subject: Backdate last_applied dates to use value of inserted_at --- pydis_site/apps/api/migrations/0084_infraction_last_applied.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0084_infraction_last_applied.py b/pydis_site/apps/api/migrations/0084_infraction_last_applied.py index 0977fa20..7704ddb8 100644 --- a/pydis_site/apps/api/migrations/0084_infraction_last_applied.py +++ b/pydis_site/apps/api/migrations/0084_infraction_last_applied.py @@ -1,7 +1,13 @@ # Generated by Django 4.0.6 on 2022-07-27 20:32 -from django.db import migrations, models import django.utils.timezone +from django.db import migrations, models +from django.apps.registry import Apps + + +def set_last_applied_to_inserted_at(apps: Apps, schema_editor): + Infractions = apps.get_model("api", "infraction") + Infractions.objects.all().update(last_applied=models.F("inserted_at")) class Migration(migrations.Migration): @@ -16,4 +22,5 @@ class Migration(migrations.Migration): name='last_applied', field=models.DateTimeField(default=django.utils.timezone.now, help_text='The date and time of when this infraction was last applied.'), ), + migrations.RunPython(set_last_applied_to_inserted_at) ] -- cgit v1.2.3 From 507676aece37d9d468cf3565915d9a146bdf2ad4 Mon Sep 17 00:00:00 2001 From: Exenifix <89513380+Exenifix@users.noreply.github.com> Date: Mon, 1 Aug 2022 10:11:54 +0300 Subject: Update pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md Co-authored-by: Vivek Ashokkumar --- .../apps/content/resources/guides/python-guides/docker-hosting-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md index 5fb55caf..e3d9dffd 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -114,7 +114,7 @@ $ docker build -t mybot . $ docker run -d --name mybot mybot:latest ``` -- `-d` flag tells Docker to run the container in detached mode, meaning it will run the container but will not give us +- `-d` flag tells Docker to run the container in detached mode, meaning it will run the container in the background of your terminal and not give us any output from it. If we don't provide it, the `run` will be giving us the output until the application exits. Discord bots aren't supposed to exit after certain time, so we do need this flag -- cgit v1.2.3 From 0f94b8e58e3357161973a37ba2e26be9740ffdb2 Mon Sep 17 00:00:00 2001 From: Exenifix Date: Mon, 1 Aug 2022 10:18:27 +0300 Subject: Added additional explanation about branch name --- .../content/resources/guides/python-guides/docker-hosting-guide.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md index e3d9dffd..36686119 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -260,15 +260,16 @@ Now we have registered our VPS as a self-hosted runner and we can run the workfl ### Write a workflow -Create a new file `.github/workflows/runner.yml` and paste the following content into it (it is easy to understand so I -am not going to give many comments) +Create a new file `.github/workflows/runner.yml` and paste the following content into it. Please pay attention to the `branches` instruction. +The GitHub's standard main branch name is `main`, however it may be named `master` or something else if you edited its name. Make sure to put +the correct branch name, otherwise it won't work. More about GitHub workflows syntax [here](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) ```yml name: Docker Runner on: push: - branches: [ master ] + branches: [ main ] jobs: run: -- cgit v1.2.3 From b572522d0afe0b856ac22239de3e67e9e8d1c721 Mon Sep 17 00:00:00 2001 From: Exenifix Date: Mon, 1 Aug 2022 10:22:27 +0300 Subject: Some grammar mistakes fix --- .../resources/guides/python-guides/docker-hosting-guide.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md index 36686119..6590cc99 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -21,7 +21,7 @@ description: This guide shows how to host a bot with Docker and GitHub Actions o - how to make docker keep the files throughout the container's runs - how to parse environment variables into container - how to use GitHub Actions for automation -- how to setup self-hosted runner +- how to set up self-hosted runner - how to use runner secrets ## Introduction @@ -96,7 +96,7 @@ CMD ["python3", "main.py"] ## Building Image and Running Container -Now update the project on your VPS and we can run the bot with Docker. +Now update the project on your VPS, so we can run the bot with Docker. 1. Build the image (dot at the end is very important) @@ -136,7 +136,7 @@ If everything went successfully, your bot will go online and will keep running! ## Using docker-compose -Just 2 commands to run a container is cool but we can shorten it down to just 1 simple command. For that, create +Just 2 commands to run a container is cool, but we can shorten it down to just 1 simple command. For that, create a `docker-compose.yml` file in project's root and fill it with the following contents: ```yml @@ -194,7 +194,7 @@ $ echo $(pwd)/mybot-data My path is `/home/exenifix/mybot-data`, yours is most likely **different**! -2. In your project, store the files that need to be persistant in a separate directory (eg. `data`) +2. In your project, store the files that need to be persistent in a separate directory (eg. `data`) 3. Add the `volumes` construction to `docker-compose` so it looks like this: ```yml @@ -243,7 +243,7 @@ services: ### Setup self-hosted runner -To run the workflow on our VPS, we will need to register it as *self hosted runner*. +To run the workflow on our VPS, we will need to register it as *self-hosted runner*. 1. Head to Settings -> Actions -> Runners 2. Press `New self-hosted runner` @@ -314,6 +314,6 @@ Now you should see the logs of your bot until the stop phrase is met. **WARNING** > The utility only reads from STDERR and redirects to STDERR, if you are using STDOUT for logs, it will not work and -> will be waiting for stop phrase forever. The utility automatically exits if bot's container is stopped (eg. error -> occured during starting) or if a log line contains a stop phrase. Make sure that your bot 100% displays a stop phrase +> will be waiting for stop phrase forever. The utility automatically exits if bot's container is stopped (e.g. error +> occurred during starting) or if a log line contains a stop phrase. Make sure that your bot 100% displays a stop phrase > when it's ready otherwise your workflow will get stuck. -- cgit v1.2.3 From a8b5cd676ce95b1b148d2cc37e008d24762ab9d7 Mon Sep 17 00:00:00 2001 From: Exenifix Date: Mon, 1 Aug 2022 10:24:33 +0300 Subject: Linting applied --- .../resources/guides/python-guides/docker-hosting-guide.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md index 6590cc99..103ddbbd 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -114,7 +114,8 @@ $ docker build -t mybot . $ docker run -d --name mybot mybot:latest ``` -- `-d` flag tells Docker to run the container in detached mode, meaning it will run the container in the background of your terminal and not give us +- `-d` flag tells Docker to run the container in detached mode, meaning it will run the container in the background of + your terminal and not give us any output from it. If we don't provide it, the `run` will be giving us the output until the application exits. Discord bots aren't supposed to exit after certain time, so we do need this flag @@ -260,9 +261,12 @@ Now we have registered our VPS as a self-hosted runner and we can run the workfl ### Write a workflow -Create a new file `.github/workflows/runner.yml` and paste the following content into it. Please pay attention to the `branches` instruction. -The GitHub's standard main branch name is `main`, however it may be named `master` or something else if you edited its name. Make sure to put -the correct branch name, otherwise it won't work. More about GitHub workflows syntax [here](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) +Create a new file `.github/workflows/runner.yml` and paste the following content into it. Please pay attention to +the `branches` instruction. +The GitHub's standard main branch name is `main`, however it may be named `master` or something else if you edited its +name. Make sure to put +the correct branch name, otherwise it won't work. More about GitHub workflows +syntax [here](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) ```yml name: Docker Runner -- cgit v1.2.3 From 562b6f0d783583838e51a86086aa441f093de102 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Wed, 3 Aug 2022 17:15:05 -0400 Subject: Added `last_applied` to `serializers` --- pydis_site/apps/api/serializers.py | 1 + 1 file changed, 1 insertion(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index e53ccffa..9228c1f4 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -176,6 +176,7 @@ class InfractionSerializer(ModelSerializer): fields = ( 'id', 'inserted_at', + 'last_applied', 'expires_at', 'active', 'user', -- cgit v1.2.3 From 460ccffe266373febcd1676d609d65f03de5a967 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 12 Aug 2022 16:32:59 +0200 Subject: Rename GitHub App Environment Variables Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/github_utils.py | 4 ++-- pydis_site/settings.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py index 7d26b147..ad24165d 100644 --- a/pydis_site/apps/api/github_utils.py +++ b/pydis_site/apps/api/github_utils.py @@ -89,9 +89,9 @@ def generate_token() -> str: { "iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()), # Issued at "exp": math.floor((now + datetime.timedelta(minutes=9)).timestamp()), # Expires at - "iss": settings.GITHUB_OAUTH_APP_ID, + "iss": settings.GITHUB_APP_ID, }, - settings.GITHUB_OAUTH_KEY, + settings.GITHUB_APP_KEY, algorithm="RS256" ) diff --git a/pydis_site/settings.py b/pydis_site/settings.py index f382b052..bbf1d3aa 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -29,19 +29,19 @@ env = environ.Env( GIT_SHA=(str, 'development'), TIMEOUT_PERIOD=(int, 5), GITHUB_TOKEN=(str, None), - GITHUB_OAUTH_APP_ID=(str, None), - GITHUB_OAUTH_KEY=(str, None), + GITHUB_APP_ID=(str, None), + GITHUB_APP_KEY=(str, None), ) GIT_SHA = env("GIT_SHA") GITHUB_API = "https://api.github.com" GITHUB_TOKEN = env("GITHUB_TOKEN") -GITHUB_OAUTH_APP_ID = env("GITHUB_OAUTH_APP_ID") -GITHUB_OAUTH_KEY = env("GITHUB_OAUTH_KEY") +GITHUB_APP_ID = env("GITHUB_APP_ID") +GITHUB_APP_KEY = env("GITHUB_APP_KEY") -if GITHUB_OAUTH_KEY and (oauth_file := Path(GITHUB_OAUTH_KEY)).is_file(): +if GITHUB_APP_KEY and (key_file := Path(GITHUB_APP_KEY)).is_file(): # Allow the OAuth key to be loaded from a file - GITHUB_OAUTH_KEY = oauth_file.read_text(encoding="utf-8") + GITHUB_APP_KEY = key_file.read_text(encoding="utf-8") sentry_sdk.init( dsn=env('SITE_DSN'), -- cgit v1.2.3 From 79fee144823ce642a48af038398478144146730c Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 12 Aug 2022 22:54:17 +0200 Subject: Bump Deadline For GitHub Artifacts Route Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/github_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py index ad24165d..5d7bcdc3 100644 --- a/pydis_site/apps/api/github_utils.py +++ b/pydis_site/apps/api/github_utils.py @@ -9,7 +9,7 @@ import jwt from pydis_site import settings -MAX_RUN_TIME = datetime.timedelta(minutes=3) +MAX_RUN_TIME = datetime.timedelta(minutes=10) """The maximum time allowed before an action is declared timed out.""" ISO_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ" """The datetime string format GitHub uses.""" -- cgit v1.2.3 From edcee093449fce833116ae3dbb15d6ccf3797652 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 13 Aug 2022 01:53:19 +0200 Subject: Add Setting For Static Builds Adds an explicit setting for static builds instead of relying on the environment variable. Signed-off-by: Hassan Abouelela --- pydis_site/apps/home/views/home.py | 8 +++----- pydis_site/apps/redirect/urls.py | 2 +- pydis_site/settings.py | 8 +++++--- pydis_site/urls.py | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py index 9bb1f8fd..8a165682 100644 --- a/pydis_site/apps/home/views/home.py +++ b/pydis_site/apps/home/views/home.py @@ -32,9 +32,7 @@ class HomeView(View): def __init__(self): """Clean up stale RepositoryMetadata.""" - self._static_build = settings.env("STATIC_BUILD") - - if not self._static_build: + if not settings.STATIC_BUILD: RepositoryMetadata.objects.exclude(repo_name__in=self.repos).delete() # If no token is defined (for example in local development), then @@ -94,7 +92,7 @@ class HomeView(View): def _get_repo_data(self) -> List[RepositoryMetadata]: """Build a list of RepositoryMetadata objects that we can use to populate the front page.""" # First off, load the timestamp of the least recently updated entry. - if self._static_build: + if settings.STATIC_BUILD: last_update = None else: last_update = ( @@ -121,7 +119,7 @@ class HomeView(View): for api_data in api_repositories.values() ] - if settings.env("STATIC_BUILD"): + if settings.STATIC_BUILD: return data else: return RepositoryMetadata.objects.bulk_create(data) diff --git a/pydis_site/apps/redirect/urls.py b/pydis_site/apps/redirect/urls.py index f7ddf45b..ed1564b0 100644 --- a/pydis_site/apps/redirect/urls.py +++ b/pydis_site/apps/redirect/urls.py @@ -31,7 +31,7 @@ class Redirect: def map_redirect(name: str, data: Redirect) -> list[URLPattern]: """Return a pattern using the Redirects app, or a static HTML redirect for static builds.""" - if not settings.env("STATIC_BUILD"): + if not settings.STATIC_BUILD: # Normal dynamic redirect return [path( data.original_path, diff --git a/pydis_site/settings.py b/pydis_site/settings.py index bbf1d3aa..315ea737 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -95,11 +95,13 @@ else: ) SECRET_KEY = env('SECRET_KEY') +STATIC_BUILD: bool = env("STATIC_BUILD") + # Application definition NON_STATIC_APPS = [ 'pydis_site.apps.api', 'pydis_site.apps.staff', -] if not env("STATIC_BUILD") else [] +] if not STATIC_BUILD else [] INSTALLED_APPS = [ *NON_STATIC_APPS, @@ -130,7 +132,7 @@ if not env("BUILDING_DOCKER"): NON_STATIC_MIDDLEWARE = [ 'django_prometheus.middleware.PrometheusBeforeMiddleware', -] if not env("STATIC_BUILD") else [] +] if not STATIC_BUILD else [] # Ensure that Prometheus middlewares are first and last here. MIDDLEWARE = [ @@ -175,7 +177,7 @@ WSGI_APPLICATION = 'pydis_site.wsgi.application' DATABASES = { 'default': env.db(), 'metricity': env.db('METRICITY_DB_URL'), -} if not env("STATIC_BUILD") else {} +} if not STATIC_BUILD else {} # Password validation # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators diff --git a/pydis_site/urls.py b/pydis_site/urls.py index 6cd31f26..0f2f6aeb 100644 --- a/pydis_site/urls.py +++ b/pydis_site/urls.py @@ -12,7 +12,7 @@ NON_STATIC_PATTERNS = [ path('pydis-api/', include('pydis_site.apps.api.urls', namespace='internal_api')), path('', include('django_prometheus.urls')), -] if not settings.env("STATIC_BUILD") else [] +] if not settings.STATIC_BUILD else [] urlpatterns = ( @@ -29,7 +29,7 @@ urlpatterns = ( ) -if not settings.env("STATIC_BUILD"): +if not settings.STATIC_BUILD: urlpatterns += ( path('staff/', include('pydis_site.apps.staff.urls', namespace='staff')), ) -- cgit v1.2.3 From d50028d6b92909a39139007f0f3bcd7c90a88420 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 13 Aug 2022 06:08:22 +0200 Subject: Add Tags To Content Listings Adds bot tags to the content page, as well as a model to go along with it. Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/migrations/0001_initial.py | 23 ++++ pydis_site/apps/content/migrations/__init__.py | 0 pydis_site/apps/content/models/__init__.py | 0 pydis_site/apps/content/models/tag.py | 17 +++ pydis_site/apps/content/resources/tags/_info.yml | 3 + pydis_site/apps/content/utils.py | 125 ++++++++++++++++++++- pydis_site/apps/content/views/page_category.py | 9 +- pydis_site/templates/content/listing.html | 10 +- pydis_site/templates/content/page.html | 2 +- 9 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 pydis_site/apps/content/migrations/0001_initial.py create mode 100644 pydis_site/apps/content/migrations/__init__.py create mode 100644 pydis_site/apps/content/models/__init__.py create mode 100644 pydis_site/apps/content/models/tag.py create mode 100644 pydis_site/apps/content/resources/tags/_info.yml (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/migrations/0001_initial.py b/pydis_site/apps/content/migrations/0001_initial.py new file mode 100644 index 00000000..15e3fc95 --- /dev/null +++ b/pydis_site/apps/content/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.6 on 2022-08-13 00:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Tag', + fields=[ + ('last_updated', models.DateTimeField(auto_now=True, help_text='The date and time this data was last fetched.')), + ('name', models.CharField(help_text="The tag's name.", max_length=50, primary_key=True, serialize=False)), + ('body', models.TextField(help_text='The content of the tag.')), + ('url', models.URLField(help_text='The URL to this tag on GitHub.')), + ], + ), + ] diff --git a/pydis_site/apps/content/migrations/__init__.py b/pydis_site/apps/content/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/content/models/__init__.py b/pydis_site/apps/content/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py new file mode 100644 index 00000000..1437b96a --- /dev/null +++ b/pydis_site/apps/content/models/tag.py @@ -0,0 +1,17 @@ +from django.db import models + + +class Tag(models.Model): + """A tag from the python-discord server.""" + + last_updated = models.DateTimeField( + help_text="The date and time this data was last fetched.", + auto_now=True, + ) + name = models.CharField( + help_text="The tag's name.", + primary_key=True, + max_length=50, + ) + body = models.TextField(help_text="The content of the tag.") + url = models.URLField(help_text="The URL to this tag on GitHub.") diff --git a/pydis_site/apps/content/resources/tags/_info.yml b/pydis_site/apps/content/resources/tags/_info.yml new file mode 100644 index 00000000..054125ec --- /dev/null +++ b/pydis_site/apps/content/resources/tags/_info.yml @@ -0,0 +1,3 @@ +title: Tags +description: Useful snippets that are often used in the server. +icon: fas fa-tags diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index d3f270ff..a4252284 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -1,14 +1,26 @@ +import datetime +import functools +import tarfile +import tempfile +from io import BytesIO from pathlib import Path -from typing import Dict, Tuple import frontmatter +import httpx import markdown import yaml from django.http import Http404 +from django.utils import timezone from markdown.extensions.toc import TocExtension +from pydis_site import settings +from .models.tag import Tag -def get_category(path: Path) -> Dict[str, str]: +TAG_URL_BASE = "https://github.com/python-discord/bot/tree/main/bot/resources/tags" +TAG_CACHE_TTL = datetime.timedelta(hours=1) + + +def get_category(path: Path) -> dict[str, str]: """Load category information by name from _info.yml.""" if not path.is_dir(): raise Http404("Category not found.") @@ -16,7 +28,7 @@ def get_category(path: Path) -> Dict[str, str]: return yaml.safe_load(path.joinpath("_info.yml").read_text(encoding="utf-8")) -def get_categories(path: Path) -> Dict[str, Dict]: +def get_categories(path: Path) -> dict[str, dict]: """Get information for all categories.""" categories = {} @@ -27,8 +39,111 @@ def get_categories(path: Path) -> Dict[str, Dict]: return categories -def get_category_pages(path: Path) -> Dict[str, Dict]: +@functools.cache +def get_tags_static() -> list[Tag]: + """ + Fetch tag information in static builds. + + This will return a cached value, so it should only be used for static builds. + """ + return fetch_tags() + + +def fetch_tags() -> list[Tag]: + """ + Fetch tag data from the GitHub API. + + The entire repository is downloaded and extracted locally because + getting file content would require one request per file, and can get rate-limited. + """ + if settings.GITHUB_TOKEN: + headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"} + else: + headers = {} + + tar_file = httpx.get( + f"{settings.GITHUB_API}/repos/python-discord/bot/tarball", + follow_redirects=True, + timeout=settings.TIMEOUT_PERIOD, + headers=headers, + ) + tar_file.raise_for_status() + + tags = [] + with tempfile.TemporaryDirectory() as folder: + with tarfile.open(fileobj=BytesIO(tar_file.content)) as repo: + included = [] + for file in repo.getmembers(): + if "/bot/resources/tags" in file.path: + included.append(file) + repo.extractall(folder, included) + + for tag_file in Path(folder).rglob("*.md"): + tags.append(Tag( + name=tag_file.name.removesuffix(".md"), + body=tag_file.read_text(encoding="utf-8"), + url=f"{TAG_URL_BASE}/{tag_file.name}" + )) + + return tags + + +def get_tags() -> list[Tag]: + """Return a list of all tags visible to the application, from the cache or API.""" + if settings.STATIC_BUILD: + last_update = None + else: + last_update = ( + Tag.objects.values_list("last_updated", flat=True) + .order_by("last_updated").first() + ) + + if last_update is None or timezone.now() >= (last_update + TAG_CACHE_TTL): + # Stale or empty cache + if settings.STATIC_BUILD: + tags = get_tags_static() + else: + tags = fetch_tags() + Tag.objects.exclude(name__in=[tag.name for tag in tags]).delete() + for tag in tags: + tag.save() + + return tags + else: + # Get tags from database + return Tag.objects.all() + + +def get_tag(name: str) -> Tag: + """Return a tag by name.""" + tags = get_tags() + for tag in tags: + if tag.name == name: + return tag + + raise Tag.DoesNotExist() + + +def get_category_pages(path: Path) -> dict[str, dict]: """Get all page names and their metadata at a category path.""" + # Special handling for tags + if path == Path(__file__).parent / "resources/tags": + tags = {} + for tag in get_tags(): + content = frontmatter.parse(tag.body)[1] + if len(content) > 100: + # Trim the preview to a maximum of 100 visible characters + # This causes some markdown to break, but we ignore that + content = content[:100] + "..." + + tags[tag.name] = { + "title": tag.name, + "description": markdown.markdown(content), + "icon": "fas fa-tag" + } + + return {name: tags[name] for name in sorted(tags)} + pages = {} for item in path.glob("*.md"): @@ -39,7 +154,7 @@ def get_category_pages(path: Path) -> Dict[str, Dict]: return pages -def get_page(path: Path) -> Tuple[str, Dict]: +def get_page(path: Path) -> tuple[str, dict]: """Get one specific page.""" if not path.is_file(): raise Http404("Page not found.") diff --git a/pydis_site/apps/content/views/page_category.py b/pydis_site/apps/content/views/page_category.py index 356eb021..01ce8402 100644 --- a/pydis_site/apps/content/views/page_category.py +++ b/pydis_site/apps/content/views/page_category.py @@ -1,4 +1,3 @@ -import typing as t from pathlib import Path import frontmatter @@ -25,7 +24,7 @@ class PageOrCategoryView(TemplateView): return super().dispatch(request, *args, **kwargs) - def get_template_names(self) -> t.List[str]: + def get_template_names(self) -> list[str]: """Checks if the view uses the page template or listing template.""" if self.page_path.is_file(): template_name = "content/page.html" @@ -36,7 +35,7 @@ class PageOrCategoryView(TemplateView): return [template_name] - def get_context_data(self, **kwargs) -> t.Dict[str, t.Any]: + def get_context_data(self, **kwargs) -> dict[str, any]: """Assign proper context variables based on what resource user requests.""" context = super().get_context_data(**kwargs) @@ -73,7 +72,7 @@ class PageOrCategoryView(TemplateView): return context @staticmethod - def _get_page_context(path: Path) -> t.Dict[str, t.Any]: + def _get_page_context(path: Path) -> dict[str, any]: page, metadata = utils.get_page(path) return { "page": page, @@ -84,7 +83,7 @@ class PageOrCategoryView(TemplateView): } @staticmethod - def _get_category_context(path: Path) -> t.Dict[str, t.Any]: + def _get_category_context(path: Path) -> dict[str, any]: category = utils.get_category(path) return { "categories": utils.get_categories(path), diff --git a/pydis_site/templates/content/listing.html b/pydis_site/templates/content/listing.html index ef0ef919..eeb6b5e2 100644 --- a/pydis_site/templates/content/listing.html +++ b/pydis_site/templates/content/listing.html @@ -1,6 +1,8 @@ +{# Base navigation screen for resources #} {% extends 'content/base.html' %} {% block page_content %} + {# Nested Categories #} {% for category, data in categories.items %}
      @@ -13,6 +15,8 @@

      {{ data.description }}

      {% endfor %} + + {# Single Pages #} {% for page, data in pages.items %}
      @@ -21,7 +25,11 @@ {{ data.title }} -

      {{ data.description }}

      + {% if "tags" in location %} +

      {{ data.description | safe }}

      + {% else %} +

      {{ data.description }}

      + {% endif %}
      {% endfor %} {% endblock %} diff --git a/pydis_site/templates/content/page.html b/pydis_site/templates/content/page.html index 759286f6..625c01f1 100644 --- a/pydis_site/templates/content/page.html +++ b/pydis_site/templates/content/page.html @@ -5,7 +5,7 @@ - + {% endblock %} {% block page_content %} -- cgit v1.2.3 From 42124deb7ea5f17bc6faf959baba8e951b567655 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 13 Aug 2022 06:10:38 +0200 Subject: Add Tag Page Template Add a template for the tag page itself, and add a route to use it. Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/urls.py | 21 ++++++++++-- pydis_site/apps/content/views/__init__.py | 3 +- pydis_site/apps/content/views/tags.py | 56 +++++++++++++++++++++++++++++++ pydis_site/static/css/content/tag.css | 7 ++++ pydis_site/templates/content/base.html | 2 +- pydis_site/templates/content/tag.html | 21 ++++++++++++ 6 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 pydis_site/apps/content/views/tags.py create mode 100644 pydis_site/static/css/content/tag.css create mode 100644 pydis_site/templates/content/tag.html (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py index f8496095..b4ffc07d 100644 --- a/pydis_site/apps/content/urls.py +++ b/pydis_site/apps/content/urls.py @@ -3,7 +3,7 @@ from pathlib import Path from django_distill import distill_path -from . import views +from . import utils, views app_name = "content" @@ -29,18 +29,33 @@ def __get_all_files(root: Path, folder: typing.Optional[Path] = None) -> list[st return results -def get_all_pages() -> typing.Iterator[dict[str, str]]: +DISTILL_RETURN = typing.Iterator[dict[str, str]] + + +def get_all_pages() -> DISTILL_RETURN: """Yield a dict of all page categories.""" for location in __get_all_files(Path("pydis_site", "apps", "content", "resources")): yield {"location": location} +def get_all_tags() -> DISTILL_RETURN: + """Return all tag names in the repository in static builds.""" + for tag in utils.get_tags_static(): + yield {"name": tag.name} + + urlpatterns = [ distill_path("", views.PageOrCategoryView.as_view(), name='pages'), + distill_path( + "tags//", + views.TagView.as_view(), + name="tag", + distill_func=get_all_tags + ), distill_path( "/", views.PageOrCategoryView.as_view(), name='page_category', distill_func=get_all_pages - ), + ) ] diff --git a/pydis_site/apps/content/views/__init__.py b/pydis_site/apps/content/views/__init__.py index 70ea1c7a..a969b1dc 100644 --- a/pydis_site/apps/content/views/__init__.py +++ b/pydis_site/apps/content/views/__init__.py @@ -1,3 +1,4 @@ from .page_category import PageOrCategoryView +from .tags import TagView -__all__ = ["PageOrCategoryView"] +__all__ = ["PageOrCategoryView", "TagView"] diff --git a/pydis_site/apps/content/views/tags.py b/pydis_site/apps/content/views/tags.py new file mode 100644 index 00000000..12e311dc --- /dev/null +++ b/pydis_site/apps/content/views/tags.py @@ -0,0 +1,56 @@ +import re + +import frontmatter +import markdown +from django.conf import settings +from django.http import Http404 +from django.urls import reverse +from django.views.generic import TemplateView + +from pydis_site.apps.content import utils +from pydis_site.apps.content.models.tag import Tag + +COMMAND_REGEX = re.compile(r"`*!tags? (?P[\w\d-]+)`*") + + +class TagView(TemplateView): + """Handles tag pages.""" + + template_name = "content/tag.html" + + def get_context_data(self, **kwargs) -> dict: + """Get the relevant context for this tag page.""" + try: + tag = utils.get_tag(kwargs.get("name")) + except Tag.DoesNotExist: + raise Http404 + + context = super().get_context_data(**kwargs) + context["page_title"] = tag.name + body = frontmatter.parse(tag.body) + content = body[1] + + # Check for tags which can be hyperlinked + start = 0 + while match := COMMAND_REGEX.search(content, start): + link = reverse("content:tag", kwargs={"name": match.group("name")}) + content = content[:match.start()] + f"[{match.group()}]({link})" + content[match.end():] + start = match.end() + + # Add support for some embed elements + if embed := body[0].get("embed"): + context["page_title"] = embed["title"] + if image := embed.get("image"): + content = f"![{embed['title']}]({image['url']})\n\n" + content + + context.update({ + "page": markdown.markdown(content, extensions=["pymdownx.superfences"]), + "tag": tag, + }) + + context["breadcrumb_items"] = [{ + "name": utils.get_category(settings.CONTENT_PAGES_PATH / location)["title"], + "path": str(location) + } for location in [".", "tags"]] + + return context diff --git a/pydis_site/static/css/content/tag.css b/pydis_site/static/css/content/tag.css new file mode 100644 index 00000000..a144ce24 --- /dev/null +++ b/pydis_site/static/css/content/tag.css @@ -0,0 +1,7 @@ +h1.title a { + color: black; +} + +h1.title a:hover { + color: #7289DA; +} diff --git a/pydis_site/templates/content/base.html b/pydis_site/templates/content/base.html index 4a19a275..dbd303a1 100644 --- a/pydis_site/templates/content/base.html +++ b/pydis_site/templates/content/base.html @@ -35,7 +35,7 @@
      -

      {{ page_title }}

      +

      {% block title_element %}{{ page_title }}{% endblock %}

      {% block page_content %}{% endblock %}
      diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html new file mode 100644 index 00000000..264f63d0 --- /dev/null +++ b/pydis_site/templates/content/tag.html @@ -0,0 +1,21 @@ +{% extends "content/page.html" %} +{% load static %} + +{% block head %} + {{ block.super }} + + {{ tag.name }} +{% endblock %} + +{% block title_element %} +
      +
      {{ block.super }}
      +
      + +
      +
      +{% endblock %} + +{% block page_content %} + {{ block.super }} +{% endblock %} -- cgit v1.2.3 From 733d5c084b1cba91a76f495b37ef0a391e5f9900 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 13 Aug 2022 06:43:05 +0200 Subject: Export Tag Model As Top Level Model Object Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/models/__init__.py | 3 +++ pydis_site/apps/content/utils.py | 2 +- pydis_site/apps/content/views/tags.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/models/__init__.py b/pydis_site/apps/content/models/__init__.py index e69de29b..2718ce94 100644 --- a/pydis_site/apps/content/models/__init__.py +++ b/pydis_site/apps/content/models/__init__.py @@ -0,0 +1,3 @@ +from .tag import Tag + +__all__ = ["Tag"] diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index a4252284..de609596 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -14,7 +14,7 @@ from django.utils import timezone from markdown.extensions.toc import TocExtension from pydis_site import settings -from .models.tag import Tag +from .models import Tag TAG_URL_BASE = "https://github.com/python-discord/bot/tree/main/bot/resources/tags" TAG_CACHE_TTL = datetime.timedelta(hours=1) diff --git a/pydis_site/apps/content/views/tags.py b/pydis_site/apps/content/views/tags.py index 12e311dc..e2cfb488 100644 --- a/pydis_site/apps/content/views/tags.py +++ b/pydis_site/apps/content/views/tags.py @@ -8,7 +8,7 @@ from django.urls import reverse from django.views.generic import TemplateView from pydis_site.apps.content import utils -from pydis_site.apps.content.models.tag import Tag +from pydis_site.apps.content.models import Tag COMMAND_REGEX = re.compile(r"`*!tags? (?P[\w\d-]+)`*") -- cgit v1.2.3 From a8fd8b823748ced96bb8ea34e8dcd8bd0dd57671 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 13 Aug 2022 08:47:29 +0200 Subject: Add Tag View Tests Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/tests/test_utils.py | 103 +++++++++++++++++++++- pydis_site/apps/content/tests/test_views.py | 128 ++++++++++++++++++++++++++++ pydis_site/apps/content/utils.py | 6 +- 3 files changed, 233 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py index be5ea897..89ef81c4 100644 --- a/pydis_site/apps/content/tests/test_utils.py +++ b/pydis_site/apps/content/tests/test_utils.py @@ -1,8 +1,16 @@ +import tarfile +import tempfile +import textwrap from pathlib import Path +from unittest import mock +import httpx +import markdown from django.http import Http404 +from django.test import TestCase -from pydis_site.apps.content import utils +from pydis_site import settings +from pydis_site.apps.content import models, utils from pydis_site.apps.content.tests.helpers import ( BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA ) @@ -96,3 +104,96 @@ class GetPageTests(MockPagesTestCase): def test_get_nonexistent_page_returns_404(self): with self.assertRaises(Http404): utils.get_page(Path(BASE_PATH, "invalid")) + + +class TagUtilsTests(TestCase): + """Tests for the tag-related utilities.""" + + @mock.patch.object(utils, "fetch_tags") + def test_static_fetch(self, fetch_mock: mock.Mock): + """Test that the static fetch function is only called at most once during static builds.""" + tags = [models.Tag(name="Name", body="body", url="url")] + fetch_mock.return_value = tags + result = utils.get_tags_static() + second_result = utils.get_tags_static() + + fetch_mock.assert_called_once() + self.assertEqual(tags, result) + self.assertEqual(tags, second_result) + + @mock.patch("httpx.get") + def test_mocked_fetch(self, get_mock: mock.Mock): + """Test that proper data is returned from fetch, but with a mocked API response.""" + bodies = ( + "This is the first tag!", + textwrap.dedent(""" + --- + frontmatter: empty + --- + This tag has frontmatter! + """), + ) + + # Generate a tar archive with a few tags + with tempfile.TemporaryDirectory() as tar_folder: + tar_folder = Path(tar_folder) + with tempfile.TemporaryDirectory() as folder: + folder = Path(folder) + (folder / "ignored_file.md").write_text("This is an ignored file.") + tags_folder = folder / "bot/resources/tags" + tags_folder.mkdir(parents=True) + + (tags_folder / "first_tag.md").write_text(bodies[0]) + (tags_folder / "second_tag.md").write_text(bodies[1]) + + with tarfile.open(tar_folder / "temp.tar", "w") as file: + file.add(folder, recursive=True) + + body = (tar_folder / "temp.tar").read_bytes() + + get_mock.return_value = httpx.Response( + status_code=200, + content=body, + request=httpx.Request("GET", "https://google.com"), + ) + + result = utils.fetch_tags() + self.assertEqual([ + models.Tag(name="first_tag", body=bodies[0], url=f"{utils.TAG_URL_BASE}/first_tag.md"), + models.Tag(name="second_tag", body=bodies[1], url=f"{utils.TAG_URL_BASE}/first_tag.md"), + ], sorted(result, key=lambda tag: tag.name)) + + def test_get_real_tag(self): + """Test that a single tag is returned if it exists.""" + tag = models.Tag.objects.create(name="real-tag") + result = utils.get_tag("real-tag") + + self.assertEqual(tag, result) + + def test_get_tag_404(self): + """Test that an error is raised when we fetch a non-existing tag.""" + models.Tag.objects.create(name="real-tag") + with self.assertRaises(models.Tag.DoesNotExist): + utils.get_tag("fake") + + def test_category_pages(self): + """Test that the category pages function returns the correct records for tags.""" + models.Tag.objects.create(name="second-tag", body="Normal body") + models.Tag.objects.create(name="first-tag", body="Normal body") + tag_body = {"description": markdown.markdown("Normal body"), "icon": "fas fa-tag"} + + result = utils.get_category_pages(settings.CONTENT_PAGES_PATH / "tags") + self.assertDictEqual({ + "first-tag": {**tag_body, "title": "first-tag"}, + "second-tag": {**tag_body, "title": "second-tag"}, + }, result) + + def test_trimmed_tag_content(self): + """Test a tag with a long body that requires trimming.""" + tag = models.Tag.objects.create(name="long-tag", body="E" * 300) + result = utils.get_category_pages(settings.CONTENT_PAGES_PATH / "tags") + self.assertDictEqual({"long-tag": { + "title": "long-tag", + "description": markdown.markdown(tag.body[:100] + "..."), + "icon": "fas fa-tag", + }}, result) diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py index eadad7e3..a5867260 100644 --- a/pydis_site/apps/content/tests/test_views.py +++ b/pydis_site/apps/content/tests/test_views.py @@ -1,9 +1,14 @@ +import textwrap from pathlib import Path from unittest import TestCase +import django.test +import markdown from django.http import Http404 from django.test import RequestFactory, SimpleTestCase, override_settings +from django.urls import reverse +from pydis_site.apps.content.models import Tag from pydis_site.apps.content.tests.helpers import ( BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA ) @@ -180,3 +185,126 @@ class PageOrCategoryViewTests(MockPagesTestCase, SimpleTestCase, TestCase): {"name": PARSED_CATEGORY_INFO["title"], "path": Path("category/subcategory")}, ] ) + + +class TagViewTests(django.test.TestCase): + """Tests for the TagView class.""" + + def setUp(self): + """Set test helpers, then set up fake filesystem.""" + super().setUp() + + def test_valid_tag_returns_200(self): + """Test that a page is returned for a valid tag.""" + Tag.objects.create(name="example", body="This is the tag body.", url="URL") + response = self.client.get("/pages/tags/example/") + self.assertEqual(200, response.status_code) + self.assertIn("This is the tag body", response.content.decode("utf-8")) + self.assertTemplateUsed(response, "content/tag.html") + + def test_invalid_tag_404(self): + """Test that a tag which doesn't exist raises a 404.""" + response = self.client.get("/pages/tags/non-existent/") + self.assertEqual(404, response.status_code) + + def test_context(self): + """Check that the context contains all the necessary data.""" + body = textwrap.dedent(""" + --- + unused: frontmatter + ---- + Tag content here. + """) + + tag = Tag.objects.create(name="example", body=body, url="URL") + response = self.client.get("/pages/tags/example/") + expected = { + "page_title": "example", + "page": markdown.markdown("Tag content here."), + "tag": tag, + } + for key in expected: + self.assertEqual( + expected[key], response.context.get(key), f"context.{key} did not match" + ) + + def test_markdown(self): + """Test that markdown content is rendered properly.""" + body = textwrap.dedent(""" + ```py + Hello world! + ``` + + **This text is in bold** + """) + + Tag.objects.create(name="example", body=body, url="URL") + response = self.client.get("/pages/tags/example/") + content = response.content.decode("utf-8") + + self.assertInHTML('Hello world!', content) + self.assertInHTML("This text is in bold", content) + + def test_embed(self): + """Test that an embed from the frontmatter is treated correctly.""" + body = textwrap.dedent(""" + --- + embed: + title: Embed title + image: + url: https://google.com + --- + Tag body. + """) + + Tag.objects.create(name="example", body=body, url="URL") + response = self.client.get("/pages/tags/example/") + content = response.content.decode("utf-8") + + self.assertInHTML('Embed title', content) + self.assertInHTML("

      Tag body.

      ", content) + + def test_embed_title(self): + """Test that the page title gets set to the embed title.""" + body = textwrap.dedent(""" + --- + embed: + title: Embed title + --- + """) + + Tag.objects.create(name="example", body=body, url="URL") + response = self.client.get("/pages/tags/example/") + self.assertEqual( + "Embed title", + response.context.get("page_title"), + "The page title must match the embed title." + ) + + def test_hyperlinked_item(self): + """Test hyperlinking of tags works as intended.""" + filler_before, filler_after = "empty filler text\n\n", "more\nfiller" + body = filler_before + "`!tags return`" + filler_after + Tag.objects.create(name="example", body=body, url="URL") + + other_url = reverse("content:tag", kwargs={"name": "return"}) + response = self.client.get("/pages/tags/example/") + self.assertEqual( + markdown.markdown(filler_before + f"[`!tags return`]({other_url})" + filler_after), + response.context.get("page") + ) + + def test_tag_root_page(self): + """Test the root tag page which lists all tags.""" + Tag.objects.create(name="tag-1") + Tag.objects.create(name="tag-2") + Tag.objects.create(name="tag-3") + + response = self.client.get("/pages/tags/") + content = response.content.decode("utf-8") + + self.assertTemplateUsed(response, "content/listing.html") + self.assertInHTML('

      Tags

      ', content) + + for tag_number in range(1, 4): + self.assertIn(f"tag-{tag_number}", content) diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index de609596..76437593 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -56,7 +56,7 @@ def fetch_tags() -> list[Tag]: The entire repository is downloaded and extracted locally because getting file content would require one request per file, and can get rate-limited. """ - if settings.GITHUB_TOKEN: + if settings.GITHUB_TOKEN: # pragma: no cover headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"} else: headers = {} @@ -90,7 +90,7 @@ def fetch_tags() -> list[Tag]: def get_tags() -> list[Tag]: """Return a list of all tags visible to the application, from the cache or API.""" - if settings.STATIC_BUILD: + if settings.STATIC_BUILD: # pragma: no cover last_update = None else: last_update = ( @@ -100,7 +100,7 @@ def get_tags() -> list[Tag]: if last_update is None or timezone.now() >= (last_update + TAG_CACHE_TTL): # Stale or empty cache - if settings.STATIC_BUILD: + if settings.STATIC_BUILD: # pragma: no cover tags = get_tags_static() else: tags = fetch_tags() -- cgit v1.2.3 From 74226511e22f04ae5a0f0975849469ad027dc963 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 13 Aug 2022 21:54:46 +0200 Subject: Clean Up Tag Link Substitution Co-authored-by: ChrisJL Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/views/tags.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/views/tags.py b/pydis_site/apps/content/views/tags.py index e2cfb488..5295537d 100644 --- a/pydis_site/apps/content/views/tags.py +++ b/pydis_site/apps/content/views/tags.py @@ -31,11 +31,10 @@ class TagView(TemplateView): content = body[1] # Check for tags which can be hyperlinked - start = 0 - while match := COMMAND_REGEX.search(content, start): + def sub(match: re.Match) -> str: link = reverse("content:tag", kwargs={"name": match.group("name")}) - content = content[:match.start()] + f"[{match.group()}]({link})" + content[match.end():] - start = match.end() + return f"[{match.group()}]({link})" + content = COMMAND_REGEX.sub(sub, content) # Add support for some embed elements if embed := body[0].get("embed"): -- cgit v1.2.3 From f2ad3eed8ef8872713666f69ec783f59006d3d81 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 13 Aug 2022 22:53:50 +0200 Subject: Improve Tag Cropping Move the tag cropping logic to the frontend, which makes it easier to crop without crossing boundaries such as link or code block boundaries. Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/utils.py | 6 +----- pydis_site/static/js/content/listing.js | 36 +++++++++++++++++++++++++++++++ pydis_site/templates/content/listing.html | 4 +++- 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 pydis_site/static/js/content/listing.js (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index 76437593..cc08f81f 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -131,14 +131,10 @@ def get_category_pages(path: Path) -> dict[str, dict]: tags = {} for tag in get_tags(): content = frontmatter.parse(tag.body)[1] - if len(content) > 100: - # Trim the preview to a maximum of 100 visible characters - # This causes some markdown to break, but we ignore that - content = content[:100] + "..." tags[tag.name] = { "title": tag.name, - "description": markdown.markdown(content), + "description": markdown.markdown(content, extensions=["pymdownx.superfences"]), "icon": "fas fa-tag" } diff --git a/pydis_site/static/js/content/listing.js b/pydis_site/static/js/content/listing.js new file mode 100644 index 00000000..3502cb2a --- /dev/null +++ b/pydis_site/static/js/content/listing.js @@ -0,0 +1,36 @@ +/** + * Trim a tag listing to only show a few lines of content. + */ +function trimTag() { + const containers = document.getElementsByClassName("tag-container"); + for (const container of containers) { + // Remove every element after the first two paragraphs + while (container.children.length > 2) { + container.removeChild(container.lastChild); + } + + // Trim down the elements if they are too long + const containerLength = container.textContent.length; + if (containerLength > 300) { + if (containerLength - container.firstChild.textContent.length > 300) { + // The first element alone takes up more than 300 characters + container.removeChild(container.lastChild); + } + + let last = container.lastChild.lastChild; + while (container.textContent.length > 300 && container.lastChild.childNodes.length > 0) { + last = container.lastChild.lastChild; + last.remove(); + } + + if (container.textContent.length > 300 && (last instanceof HTMLElement && last.tagName !== "CODE")) { + // Add back the final element (up to a period if possible) + const stop = last.textContent.indexOf("."); + last.textContent = last.textContent.slice(0, stop > 0 ? stop + 1: null); + container.lastChild.appendChild(last); + } + } + } +} + +trimTag(); diff --git a/pydis_site/templates/content/listing.html b/pydis_site/templates/content/listing.html index eeb6b5e2..098f4237 100644 --- a/pydis_site/templates/content/listing.html +++ b/pydis_site/templates/content/listing.html @@ -1,5 +1,6 @@ {# Base navigation screen for resources #} {% extends 'content/base.html' %} +{% load static %} {% block page_content %} {# Nested Categories #} @@ -26,10 +27,11 @@ {{ data.title }} {% if "tags" in location %} -

      {{ data.description | safe }}

      +
      {{ data.description | safe }}
      {% else %}

      {{ data.description }}

      {% endif %} {% endfor %} + {% endblock %} -- cgit v1.2.3 From b4911d03faf8eecf5c4cced6f8036b0b2ef01d58 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sun, 14 Aug 2022 04:44:57 +0200 Subject: Move Tag URL To Property And Add Group The URLs can be simply constructed using the other tag properties, so they were removed from the database in favor of a property. A group field was also added to support tags within groups. Signed-off-by: Hassan Abouelela --- .../migrations/0002_remove_tag_url_tag_group.py | 22 ++++++++++++++++++++++ pydis_site/apps/content/models/tag.py | 17 ++++++++++++++++- pydis_site/apps/content/tests/test_utils.py | 6 +++--- pydis_site/apps/content/tests/test_views.py | 12 ++++++------ 4 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py b/pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py new file mode 100644 index 00000000..e59077f0 --- /dev/null +++ b/pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py @@ -0,0 +1,22 @@ +# Generated by Django 4.0.6 on 2022-08-13 23:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='tag', + name='url', + ), + migrations.AddField( + model_name='tag', + name='group', + field=models.CharField(help_text='The group the tag belongs to.', max_length=50, null=True), + ), + ] diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py index 1437b96a..01264ff1 100644 --- a/pydis_site/apps/content/models/tag.py +++ b/pydis_site/apps/content/models/tag.py @@ -4,6 +4,8 @@ from django.db import models class Tag(models.Model): """A tag from the python-discord server.""" + URL_BASE = "https://github.com/python-discord/bot/tree/main/bot/resources/tags" + last_updated = models.DateTimeField( help_text="The date and time this data was last fetched.", auto_now=True, @@ -13,5 +15,18 @@ class Tag(models.Model): primary_key=True, max_length=50, ) + group = models.CharField( + help_text="The group the tag belongs to.", + null=True, + max_length=50, + ) body = models.TextField(help_text="The content of the tag.") - url = models.URLField(help_text="The URL to this tag on GitHub.") + + @property + def url(self) -> str: + """Get the URL of the tag on GitHub.""" + url = Tag.URL_BASE + if self.group: + url += f"/{self.group}" + url += f"/{self.name}.md" + return url diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py index 89ef81c4..a5d5dcb4 100644 --- a/pydis_site/apps/content/tests/test_utils.py +++ b/pydis_site/apps/content/tests/test_utils.py @@ -112,7 +112,7 @@ class TagUtilsTests(TestCase): @mock.patch.object(utils, "fetch_tags") def test_static_fetch(self, fetch_mock: mock.Mock): """Test that the static fetch function is only called at most once during static builds.""" - tags = [models.Tag(name="Name", body="body", url="url")] + tags = [models.Tag(name="Name", body="body")] fetch_mock.return_value = tags result = utils.get_tags_static() second_result = utils.get_tags_static() @@ -159,8 +159,8 @@ class TagUtilsTests(TestCase): result = utils.fetch_tags() self.assertEqual([ - models.Tag(name="first_tag", body=bodies[0], url=f"{utils.TAG_URL_BASE}/first_tag.md"), - models.Tag(name="second_tag", body=bodies[1], url=f"{utils.TAG_URL_BASE}/first_tag.md"), + models.Tag(name="first_tag", body=bodies[0]), + models.Tag(name="second_tag", body=bodies[1]), ], sorted(result, key=lambda tag: tag.name)) def test_get_real_tag(self): diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py index a5867260..c4d3474e 100644 --- a/pydis_site/apps/content/tests/test_views.py +++ b/pydis_site/apps/content/tests/test_views.py @@ -196,7 +196,7 @@ class TagViewTests(django.test.TestCase): def test_valid_tag_returns_200(self): """Test that a page is returned for a valid tag.""" - Tag.objects.create(name="example", body="This is the tag body.", url="URL") + Tag.objects.create(name="example", body="This is the tag body.") response = self.client.get("/pages/tags/example/") self.assertEqual(200, response.status_code) self.assertIn("This is the tag body", response.content.decode("utf-8")) @@ -216,7 +216,7 @@ class TagViewTests(django.test.TestCase): Tag content here. """) - tag = Tag.objects.create(name="example", body=body, url="URL") + tag = Tag.objects.create(name="example", body=body) response = self.client.get("/pages/tags/example/") expected = { "page_title": "example", @@ -238,7 +238,7 @@ class TagViewTests(django.test.TestCase): **This text is in bold** """) - Tag.objects.create(name="example", body=body, url="URL") + Tag.objects.create(name="example", body=body) response = self.client.get("/pages/tags/example/") content = response.content.decode("utf-8") @@ -257,7 +257,7 @@ class TagViewTests(django.test.TestCase): Tag body. """) - Tag.objects.create(name="example", body=body, url="URL") + Tag.objects.create(name="example", body=body) response = self.client.get("/pages/tags/example/") content = response.content.decode("utf-8") @@ -273,7 +273,7 @@ class TagViewTests(django.test.TestCase): --- """) - Tag.objects.create(name="example", body=body, url="URL") + Tag.objects.create(name="example", body=body) response = self.client.get("/pages/tags/example/") self.assertEqual( "Embed title", @@ -285,7 +285,7 @@ class TagViewTests(django.test.TestCase): """Test hyperlinking of tags works as intended.""" filler_before, filler_after = "empty filler text\n\n", "more\nfiller" body = filler_before + "`!tags return`" + filler_after - Tag.objects.create(name="example", body=body, url="URL") + Tag.objects.create(name="example", body=body) other_url = reverse("content:tag", kwargs={"name": "return"}) response = self.client.get("/pages/tags/example/") -- cgit v1.2.3 From 45cdb27a82297ede18d7bd908213dde54fef06a9 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sun, 14 Aug 2022 05:34:27 +0200 Subject: Add Tag Group Support Adds support for tag groups in content. This involves some modification to the routing, and templating. Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/urls.py | 12 ++- pydis_site/apps/content/utils.py | 101 ++++++++++++++++++++----- pydis_site/apps/content/views/page_category.py | 5 +- pydis_site/apps/content/views/tags.py | 79 +++++++++++++++---- pydis_site/static/css/content/color.css | 7 ++ pydis_site/static/css/content/tag.css | 8 -- pydis_site/static/js/content/listing.js | 5 ++ pydis_site/templates/content/listing.html | 17 ++++- pydis_site/templates/content/tag.html | 5 +- 9 files changed, 187 insertions(+), 52 deletions(-) create mode 100644 pydis_site/static/css/content/color.css (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py index b4ffc07d..03c0015a 100644 --- a/pydis_site/apps/content/urls.py +++ b/pydis_site/apps/content/urls.py @@ -39,15 +39,21 @@ def get_all_pages() -> DISTILL_RETURN: def get_all_tags() -> DISTILL_RETURN: - """Return all tag names in the repository in static builds.""" + """Return all tag names and groups in static builds.""" + groups = {None} for tag in utils.get_tags_static(): - yield {"name": tag.name} + groups.add(tag.group) + yield {"location": (f"{tag.group}/" if tag.group else "") + tag.name} + + groups.remove(None) + for group in groups: + yield {"location": group} urlpatterns = [ distill_path("", views.PageOrCategoryView.as_view(), name='pages'), distill_path( - "tags//", + "tags//", views.TagView.as_view(), name="tag", distill_func=get_all_tags diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index cc08f81f..da6a024d 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -2,6 +2,7 @@ import datetime import functools import tarfile import tempfile +import typing from io import BytesIO from pathlib import Path @@ -16,7 +17,6 @@ from markdown.extensions.toc import TocExtension from pydis_site import settings from .models import Tag -TAG_URL_BASE = "https://github.com/python-discord/bot/tree/main/bot/resources/tags" TAG_CACHE_TTL = datetime.timedelta(hours=1) @@ -44,9 +44,13 @@ def get_tags_static() -> list[Tag]: """ Fetch tag information in static builds. + This also includes some fake tags to preview the tag groups feature. This will return a cached value, so it should only be used for static builds. """ - return fetch_tags() + tags = fetch_tags() + for tag in tags[3:5]: + tag.group = "very-cool-group" + return tags def fetch_tags() -> list[Tag]: @@ -79,10 +83,15 @@ def fetch_tags() -> list[Tag]: repo.extractall(folder, included) for tag_file in Path(folder).rglob("*.md"): + group = None + if tag_file.parent.name != "tags": + # Tags in sub-folders are considered part of a group + group = tag_file.parent.name + tags.append(Tag( name=tag_file.name.removesuffix(".md"), + group=group, body=tag_file.read_text(encoding="utf-8"), - url=f"{TAG_URL_BASE}/{tag_file.name}" )) return tags @@ -114,31 +123,85 @@ def get_tags() -> list[Tag]: return Tag.objects.all() -def get_tag(name: str) -> Tag: - """Return a tag by name.""" - tags = get_tags() - for tag in tags: - if tag.name == name: +def get_tag(path: str) -> typing.Union[Tag, list[Tag]]: + """ + Return a tag based on the search location. + + The tag name and group must match. If only one argument is provided in the path, + it's assumed to either be a group name, or a no-group tag name. + + If it's a group name, a list of tags which belong to it is returned. + """ + path = path.split("/") + if len(path) == 2: + group, name = path[0], path[1] + else: + name = path[0] + group = None + + matches = [] + for tag in get_tags(): + if tag.name == name and tag.group == group: return tag + elif tag.group == name and group is None: + matches.append(tag) + + if matches: + return matches raise Tag.DoesNotExist() -def get_category_pages(path: Path) -> dict[str, dict]: - """Get all page names and their metadata at a category path.""" - # Special handling for tags - if path == Path(__file__).parent / "resources/tags": - tags = {} - for tag in get_tags(): - content = frontmatter.parse(tag.body)[1] +def get_tag_category( + tags: typing.Optional[list[Tag]] = None, *, collapse_groups: bool +) -> dict[str, dict]: + """ + Generate context data for `tags`, or all tags if None. + + If `tags` is None, `get_tag` is used to populate the data. + If `collapse_groups` is True, tags with parent groups are not included in the list, + and instead the parent itself is included as a single entry with it's sub-tags + in the description. + """ + if not tags: + tags = get_tags() + + data = [] + groups = {} - tags[tag.name] = { + # Create all the metadata for the tags + for tag in tags: + if tag.group is None or not collapse_groups: + content = frontmatter.parse(tag.body)[1] + data.append({ "title": tag.name, "description": markdown.markdown(content, extensions=["pymdownx.superfences"]), - "icon": "fas fa-tag" - } + "icon": "fas fa-tag", + }) + else: + if tag.group not in groups: + groups[tag.group] = { + "title": tag.group, + "description": [tag.name], + "icon": "fas fa-tags", + } + else: + groups[tag.group]["description"].append(tag.name) - return {name: tags[name] for name in sorted(tags)} + # Flatten group description into a single string + for group in groups.values(): + group["description"] = "Contains the following tags: " + ", ".join(group["description"]) + data.append(group) + + # Sort the tags, and return them in the proper format + return {tag["title"]: tag for tag in sorted(data, key=lambda tag: tag["title"].lower())} + + +def get_category_pages(path: Path) -> dict[str, dict]: + """Get all page names and their metadata at a category path.""" + # Special handling for tags + if path == Path(__file__).parent / "resources/tags": + return get_tag_category(collapse_groups=True) pages = {} diff --git a/pydis_site/apps/content/views/page_category.py b/pydis_site/apps/content/views/page_category.py index 01ce8402..062c2bc1 100644 --- a/pydis_site/apps/content/views/page_category.py +++ b/pydis_site/apps/content/views/page_category.py @@ -5,7 +5,7 @@ from django.conf import settings from django.http import Http404, HttpRequest, HttpResponse from django.views.generic import TemplateView -from pydis_site.apps.content import utils +from pydis_site.apps.content import models, utils class PageOrCategoryView(TemplateView): @@ -91,4 +91,7 @@ class PageOrCategoryView(TemplateView): "page_title": category["title"], "page_description": category["description"], "icon": category.get("icon"), + "app_name": "content:page_category", + "is_tag_listing": "/resources/tags" in path.as_posix(), + "tag_url": models.Tag.URL_BASE, } diff --git a/pydis_site/apps/content/views/tags.py b/pydis_site/apps/content/views/tags.py index 5295537d..a8df65db 100644 --- a/pydis_site/apps/content/views/tags.py +++ b/pydis_site/apps/content/views/tags.py @@ -1,4 +1,5 @@ import re +import typing import frontmatter import markdown @@ -16,23 +17,65 @@ COMMAND_REGEX = re.compile(r"`*!tags? (?P[\w\d-]+)`*") class TagView(TemplateView): """Handles tag pages.""" - template_name = "content/tag.html" + tag: typing.Union[Tag, list[Tag]] + is_group: bool + + def setup(self, *args, **kwargs) -> None: + """Look for a tag, and configure the view.""" + super().setup(*args, **kwargs) - def get_context_data(self, **kwargs) -> dict: - """Get the relevant context for this tag page.""" try: - tag = utils.get_tag(kwargs.get("name")) + self.tag = utils.get_tag(kwargs.get("location")) + self.is_group = isinstance(self.tag, list) except Tag.DoesNotExist: raise Http404 + def get_template_names(self) -> list[str]: + """Either return the tag page template, or the listing.""" + if self.is_group: + template_name = "content/listing.html" + else: + template_name = "content/tag.html" + + return [template_name] + + def get_context_data(self, **kwargs) -> dict: + """Get the relevant context for this tag page or group.""" context = super().get_context_data(**kwargs) - context["page_title"] = tag.name + context["breadcrumb_items"] = [{ + "name": utils.get_category(settings.CONTENT_PAGES_PATH / location)["title"], + "path": location, + } for location in (".", "tags")] + + if self.is_group: + self._set_group_context(context, self.tag) + else: + self._set_tag_context(context, self.tag) + + return context + + @staticmethod + def _set_tag_context(context: dict[str, any], tag: Tag) -> None: + """Update the context with the information for a tag page.""" + context.update({ + "page_title": tag.name, + "tag": tag, + }) + + if tag.group: + # Add group names to the breadcrumbs + context["breadcrumb_items"].append({ + "name": tag.group, + "path": f"tags/{tag.group}", + }) + + # Clean up tag body body = frontmatter.parse(tag.body) content = body[1] # Check for tags which can be hyperlinked def sub(match: re.Match) -> str: - link = reverse("content:tag", kwargs={"name": match.group("name")}) + link = reverse("content:tag", kwargs={"location": match.group("name")}) return f"[{match.group()}]({link})" content = COMMAND_REGEX.sub(sub, content) @@ -42,14 +85,20 @@ class TagView(TemplateView): if image := embed.get("image"): content = f"![{embed['title']}]({image['url']})\n\n" + content + # Insert the content + context["page"] = markdown.markdown(content, extensions=["pymdownx.superfences"]) + + @staticmethod + def _set_group_context(context: dict[str, any], tags: list[Tag]) -> None: + """Update the context with the information for a group of tags.""" + group = tags[0].group context.update({ - "page": markdown.markdown(content, extensions=["pymdownx.superfences"]), - "tag": tag, + "categories": {}, + "pages": utils.get_tag_category(tags, collapse_groups=False), + "page_title": group, + "icon": "fab fa-tags", + "is_tag_listing": True, + "app_name": "content:tag", + "path": f"{group}/", + "tag_url": f"{tags[0].URL_BASE}/{group}" }) - - context["breadcrumb_items"] = [{ - "name": utils.get_category(settings.CONTENT_PAGES_PATH / location)["title"], - "path": str(location) - } for location in [".", "tags"]] - - return context diff --git a/pydis_site/static/css/content/color.css b/pydis_site/static/css/content/color.css new file mode 100644 index 00000000..f4801c28 --- /dev/null +++ b/pydis_site/static/css/content/color.css @@ -0,0 +1,7 @@ +.content .fa-github { + color: black; +} + +.content .fa-github:hover { + color: #7289DA; +} diff --git a/pydis_site/static/css/content/tag.css b/pydis_site/static/css/content/tag.css index a3db046c..ec45bfc7 100644 --- a/pydis_site/static/css/content/tag.css +++ b/pydis_site/static/css/content/tag.css @@ -1,11 +1,3 @@ -h1.title a { - color: black; -} - -h1.title a:hover { - color: #7289DA; -} - .content a * { /* This is the original color, but propagated down the chain */ /* which allows for elements inside links, such as codeblocks */ diff --git a/pydis_site/static/js/content/listing.js b/pydis_site/static/js/content/listing.js index 3502cb2a..4b722632 100644 --- a/pydis_site/static/js/content/listing.js +++ b/pydis_site/static/js/content/listing.js @@ -4,6 +4,11 @@ function trimTag() { const containers = document.getElementsByClassName("tag-container"); for (const container of containers) { + if (container.textContent.startsWith("Contains the following tags:")) { + // Tag group, no need to trim + continue; + } + // Remove every element after the first two paragraphs while (container.children.length > 2) { container.removeChild(container.lastChild); diff --git a/pydis_site/templates/content/listing.html b/pydis_site/templates/content/listing.html index 098f4237..934b95f6 100644 --- a/pydis_site/templates/content/listing.html +++ b/pydis_site/templates/content/listing.html @@ -2,6 +2,19 @@ {% extends 'content/base.html' %} {% load static %} +{# Show a GitHub button on tag pages #} +{% block title_element %} +{% if is_tag_listing %} + +
      +
      {{ block.super }}
      +
      + +
      +
      +{% endif %} +{% endblock %} + {% block page_content %} {# Nested Categories #} {% for category, data in categories.items %} @@ -23,10 +36,10 @@ - + {{ data.title }} - {% if "tags" in location %} + {% if is_tag_listing %}
      {{ data.description | safe }}
      {% else %}

      {{ data.description }}

      diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html index 264f63d0..9bd65744 100644 --- a/pydis_site/templates/content/tag.html +++ b/pydis_site/templates/content/tag.html @@ -3,6 +3,7 @@ {% block head %} {{ block.super }} + {{ tag.name }} {% endblock %} @@ -15,7 +16,3 @@ {% endblock %} - -{% block page_content %} - {{ block.super }} -{% endblock %} -- cgit v1.2.3 From 5aeffa5ab4dd8b251d2ae742d1a1e2bf3ba461c7 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sun, 14 Aug 2022 07:14:50 +0200 Subject: Update Tests For Tag Groups Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/tests/test_utils.py | 110 +++++++++++++++++++++++----- pydis_site/apps/content/tests/test_views.py | 68 ++++++++++++++++- pydis_site/apps/content/utils.py | 3 +- 3 files changed, 157 insertions(+), 24 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py index a5d5dcb4..556f633c 100644 --- a/pydis_site/apps/content/tests/test_utils.py +++ b/pydis_site/apps/content/tests/test_utils.py @@ -132,6 +132,7 @@ class TagUtilsTests(TestCase): --- This tag has frontmatter! """), + "This is a grouped tag!", ) # Generate a tar archive with a few tags @@ -146,6 +147,10 @@ class TagUtilsTests(TestCase): (tags_folder / "first_tag.md").write_text(bodies[0]) (tags_folder / "second_tag.md").write_text(bodies[1]) + group_folder = tags_folder / "some_group" + group_folder.mkdir() + (group_folder / "grouped_tag.md").write_text(bodies[2]) + with tarfile.open(tar_folder / "temp.tar", "w") as file: file.add(folder, recursive=True) @@ -158,10 +163,15 @@ class TagUtilsTests(TestCase): ) result = utils.fetch_tags() - self.assertEqual([ + + def sort(_tag: models.Tag) -> str: + return _tag.name + + self.assertEqual(sorted([ models.Tag(name="first_tag", body=bodies[0]), models.Tag(name="second_tag", body=bodies[1]), - ], sorted(result, key=lambda tag: tag.name)) + models.Tag(name="grouped_tag", body=bodies[2], group=group_folder.name), + ], key=sort), sorted(result, key=sort)) def test_get_real_tag(self): """Test that a single tag is returned if it exists.""" @@ -170,30 +180,92 @@ class TagUtilsTests(TestCase): self.assertEqual(tag, result) + def test_get_grouped_tag(self): + """Test fetching a tag from a group.""" + tag = models.Tag.objects.create(name="real-tag", group="real-group") + result = utils.get_tag("real-group/real-tag") + + self.assertEqual(tag, result) + + def test_get_group(self): + """Test fetching a group of tags.""" + included = [ + models.Tag.objects.create(name="tag-1", group="real-group"), + models.Tag.objects.create(name="tag-2", group="real-group"), + models.Tag.objects.create(name="tag-3", group="real-group"), + ] + + models.Tag.objects.create(name="not-included-1") + models.Tag.objects.create(name="not-included-2", group="other-group") + + result = utils.get_tag("real-group") + self.assertListEqual(included, result) + def test_get_tag_404(self): """Test that an error is raised when we fetch a non-existing tag.""" models.Tag.objects.create(name="real-tag") with self.assertRaises(models.Tag.DoesNotExist): utils.get_tag("fake") - def test_category_pages(self): - """Test that the category pages function returns the correct records for tags.""" - models.Tag.objects.create(name="second-tag", body="Normal body") - models.Tag.objects.create(name="first-tag", body="Normal body") - tag_body = {"description": markdown.markdown("Normal body"), "icon": "fas fa-tag"} - + @mock.patch.object(utils, "get_tag_category") + def test_category_pages(self, get_mock: mock.Mock): + """Test that the category pages function calls the correct method for tags.""" + tag = models.Tag.objects.create(name="tag") + get_mock.return_value = tag result = utils.get_category_pages(settings.CONTENT_PAGES_PATH / "tags") + self.assertEqual(tag, result) + get_mock.assert_called_once_with(collapse_groups=True) + + def test_get_category_root(self): + """Test that all tags are returned and formatted properly for the tag root page.""" + body = "normal body" + base = {"description": markdown.markdown(body), "icon": "fas fa-tag"} + + models.Tag.objects.create(name="tag-1", body=body), + models.Tag.objects.create(name="tag-2", body=body), + models.Tag.objects.create(name="tag-3", body=body), + + models.Tag.objects.create(name="tag-4", body=body, group="tag-group") + models.Tag.objects.create(name="tag-5", body=body, group="tag-group") + + result = utils.get_tag_category(collapse_groups=True) + self.assertDictEqual({ - "first-tag": {**tag_body, "title": "first-tag"}, - "second-tag": {**tag_body, "title": "second-tag"}, + "tag-1": {**base, "title": "tag-1"}, + "tag-2": {**base, "title": "tag-2"}, + "tag-3": {**base, "title": "tag-3"}, + "tag-group": { + "title": "tag-group", + "description": "Contains the following tags: tag-4, tag-5", + "icon": "fas fa-tags" + } }, result) - def test_trimmed_tag_content(self): - """Test a tag with a long body that requires trimming.""" - tag = models.Tag.objects.create(name="long-tag", body="E" * 300) - result = utils.get_category_pages(settings.CONTENT_PAGES_PATH / "tags") - self.assertDictEqual({"long-tag": { - "title": "long-tag", - "description": markdown.markdown(tag.body[:100] + "..."), - "icon": "fas fa-tag", - }}, result) + def test_get_category_group(self): + """Test the function for a group root page.""" + body = "normal body" + base = {"description": markdown.markdown(body), "icon": "fas fa-tag"} + + included = [ + models.Tag.objects.create(name="tag-1", body=body, group="group"), + models.Tag.objects.create(name="tag-2", body=body, group="group"), + ] + models.Tag.objects.create(name="not-included", body=body) + + result = utils.get_tag_category(included, collapse_groups=False) + self.assertDictEqual({ + "tag-1": {**base, "title": "tag-1"}, + "tag-2": {**base, "title": "tag-2"}, + }, result) + + def test_tag_url(self): + """Test that tag URLs are generated correctly.""" + cases = [ + ({"name": "tag"}, f"{models.Tag.URL_BASE}/tag.md"), + ({"name": "grouped", "group": "abc"}, f"{models.Tag.URL_BASE}/abc/grouped.md"), + ] + + for options, url in cases: + tag = models.Tag(**options) + with self.subTest(tag=tag): + self.assertEqual(url, tag.url) diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py index c4d3474e..c5c25be4 100644 --- a/pydis_site/apps/content/tests/test_views.py +++ b/pydis_site/apps/content/tests/test_views.py @@ -194,6 +194,23 @@ class TagViewTests(django.test.TestCase): """Set test helpers, then set up fake filesystem.""" super().setUp() + def test_routing(self): + """Test that the correct template is returned for each route.""" + Tag.objects.create(name="example") + Tag.objects.create(name="grouped-tag", group="group-name") + + cases = [ + ("/pages/tags/example/", "content/tag.html"), + ("/pages/tags/group-name/", "content/listing.html"), + ("/pages/tags/group-name/grouped-tag/", "content/tag.html"), + ] + + for url, template in cases: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(200, response.status_code) + self.assertTemplateUsed(response, template) + def test_valid_tag_returns_200(self): """Test that a page is returned for a valid tag.""" Tag.objects.create(name="example", body="This is the tag body.") @@ -207,8 +224,8 @@ class TagViewTests(django.test.TestCase): response = self.client.get("/pages/tags/non-existent/") self.assertEqual(404, response.status_code) - def test_context(self): - """Check that the context contains all the necessary data.""" + def test_context_tag(self): + """Test that the context contains the required data for a tag.""" body = textwrap.dedent(""" --- unused: frontmatter @@ -222,12 +239,55 @@ class TagViewTests(django.test.TestCase): "page_title": "example", "page": markdown.markdown("Tag content here."), "tag": tag, + "breadcrumb_items": [ + {"name": "Pages", "path": "."}, + {"name": "Tags", "path": "tags"}, + ] } for key in expected: self.assertEqual( expected[key], response.context.get(key), f"context.{key} did not match" ) + def test_context_grouped_tag(self): + """ + Test the context for a tag in a group. + + The only difference between this and a regular tag are the breadcrumbs, + so only those are checked. + """ + Tag.objects.create(name="example", body="Body text", group="group-name") + response = self.client.get("/pages/tags/group-name/example/") + self.assertListEqual([ + {"name": "Pages", "path": "."}, + {"name": "Tags", "path": "tags"}, + {"name": "group-name", "path": "tags/group-name"}, + ], response.context.get("breadcrumb_items")) + + def test_group_page(self): + """Test rendering of a group's root page.""" + Tag.objects.create(name="tag-1", body="Body 1", group="group-name") + Tag.objects.create(name="tag-2", body="Body 2", group="group-name") + Tag.objects.create(name="not-included") + + response = self.client.get("/pages/tags/group-name/") + content = response.content.decode("utf-8") + + self.assertInHTML("
      group-name
      ", content) + self.assertInHTML( + f"", + content + ) + self.assertIn(">tag-1", content) + self.assertIn(">tag-2", content) + self.assertNotIn( + ">not-included", + content, + "Tags not in this group shouldn't be rendered." + ) + + self.assertInHTML("

      Body 1

      ", content) + def test_markdown(self): """Test that markdown content is rendered properly.""" body = textwrap.dedent(""" @@ -287,7 +347,7 @@ class TagViewTests(django.test.TestCase): body = filler_before + "`!tags return`" + filler_after Tag.objects.create(name="example", body=body) - other_url = reverse("content:tag", kwargs={"name": "return"}) + other_url = reverse("content:tag", kwargs={"location": "return"}) response = self.client.get("/pages/tags/example/") self.assertEqual( markdown.markdown(filler_before + f"[`!tags return`]({other_url})" + filler_after), @@ -304,7 +364,7 @@ class TagViewTests(django.test.TestCase): content = response.content.decode("utf-8") self.assertTemplateUsed(response, "content/listing.html") - self.assertInHTML('

      Tags

      ', content) + self.assertInHTML('
      Tags
      ', content) for tag_number in range(1, 4): self.assertIn(f"tag-{tag_number}", content) diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index da6a024d..11100ba5 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -48,7 +48,7 @@ def get_tags_static() -> list[Tag]: This will return a cached value, so it should only be used for static builds. """ tags = fetch_tags() - for tag in tags[3:5]: + for tag in tags[3:5]: # pragma: no cover tag.group = "very-cool-group" return tags @@ -190,6 +190,7 @@ def get_tag_category( # Flatten group description into a single string for group in groups.values(): + # If the following string is updated, make sure to update it in the frontend JS as well group["description"] = "Contains the following tags: " + ", ".join(group["description"]) data.append(group) -- cgit v1.2.3 From 25db8f564c0f5c473b165ccab14413ca4471ac7d Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sun, 14 Aug 2022 07:20:34 +0200 Subject: Explicitly Specify Infraction Time In Tests The infraction tests checked that the route returned infractions in the correct order, which is based on insertion time. This can be fragile however, since the insertion time can be very close (or identical) during the tests. That became especially more likely with PR #741 (commit 149e67b4) which improved database access speed. This is fixed by explicitly specifying the insertion time, and spacing them out properly. Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/tests/test_infractions.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index f1107734..89ee4e23 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -56,15 +56,17 @@ class InfractionTests(AuthenticatedAPITestCase): type='ban', reason='He terk my jerb!', hidden=True, + inserted_at=dt(2020, 10, 10, 0, 0, 0, tzinfo=timezone.utc), expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc), - active=True + active=True, ) cls.ban_inactive = Infraction.objects.create( user_id=cls.user.id, actor_id=cls.user.id, type='ban', reason='James is an ass, and we won\'t be working with him again.', - active=False + active=False, + inserted_at=dt(2020, 10, 10, 0, 1, 0, tzinfo=timezone.utc), ) cls.mute_permanent = Infraction.objects.create( user_id=cls.user.id, @@ -72,7 +74,8 @@ class InfractionTests(AuthenticatedAPITestCase): type='mute', reason='He has a filthy mouth and I am his soap.', active=True, - expires_at=None + inserted_at=dt(2020, 10, 10, 0, 2, 0, tzinfo=timezone.utc), + expires_at=None, ) cls.superstar_expires_soon = Infraction.objects.create( user_id=cls.user.id, @@ -80,7 +83,8 @@ class InfractionTests(AuthenticatedAPITestCase): type='superstar', reason='This one doesn\'t matter anymore.', active=True, - expires_at=dt.now(timezone.utc) + datetime.timedelta(hours=5) + inserted_at=dt(2020, 10, 10, 0, 3, 0, tzinfo=timezone.utc), + expires_at=dt.now(timezone.utc) + datetime.timedelta(hours=5), ) cls.voiceban_expires_later = Infraction.objects.create( user_id=cls.user.id, @@ -88,7 +92,8 @@ class InfractionTests(AuthenticatedAPITestCase): type='voice_ban', reason='Jet engine mic', active=True, - expires_at=dt.now(timezone.utc) + datetime.timedelta(days=5) + inserted_at=dt(2020, 10, 10, 0, 4, 0, tzinfo=timezone.utc), + expires_at=dt.now(timezone.utc) + datetime.timedelta(days=5), ) def test_list_all(self): -- cgit v1.2.3 From acd4238fcb9c0135a548eb9bad43923fc41e983e Mon Sep 17 00:00:00 2001 From: Robin <74519799+Robin5605@users.noreply.github.com> Date: Mon, 15 Aug 2022 17:40:24 -0500 Subject: Migrate on_command_error pin As part of the migration of the #discord-bots pinned content from discord to the site, this PR migrates the pin regarding `on_command_error` of the discord.py library "eating" (silencing) unhandled errors. --- .../guides/python-guides/proper-error-handling.md | 70 ++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md new file mode 100644 index 00000000..9307169d --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md @@ -0,0 +1,70 @@ +--- +title: Proper error handling in discord.py +description: Are you not getting any errors? This might be why! +--- +If you're not recieving any errors in your console, even though you know you should be, try this: + +# With bot subclass: +```py +import discord +from discord.ext import commands + +import traceback +import sys + +class MyBot(commands.Bot): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + async def on_command_error(self, ctx: commands.Context, error): + # Handle your errors here + if isinstance(error, commands.MemberNotFound): + await ctx.send("I could not find member '{error.argument}'. Please try again") + + elif isinstance(error, commands.MissingRequiredArgument): + await ctx.send(f"'{error.param.name}' is a required argument.") + else: + # All unhandled errors will print their original traceback + print(f'Ignoring exception in command {ctx.command}:', file=sys.stderr) + traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) + +bot = MyBot(command_prefix="!", intents=discord.Intents.default()) + +bot.run("token") +``` + +# Without bot subclass +```py +import discord +from discord.ext import commands + +import traceback +import sys + +async def on_command_error(self, ctx: commands.Context, error): + # Handle your errors here + if isinstance(error, commands.MemberNotFound): + await ctx.send("I could not find member '{error.argument}'. Please try again") + + elif isinstance(error, commands.MissingRequiredArgument): + await ctx.send(f"'{error.param.name}' is a required argument.") + else: + # All unhandled errors will print their original traceback + print(f'Ignoring exception in command {ctx.command}:', file=sys.stderr) + traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +bot.on_command_error = on_command_error + +bot.run("token") +``` + + +Make sure to import `traceback` and `sys`! + +------------------------------------------------------------------------------------------------------------- + +Useful Links: +- [FAQ](https://discordpy.readthedocs.io/en/latest/faq.html) +- [Simple Error Handling](https://gist.github.com/EvieePy/7822af90858ef65012ea500bcecf1612) -- cgit v1.2.3 From d64ed9b4d269d9731267c6d7b088555ea3cf4e31 Mon Sep 17 00:00:00 2001 From: Robin <74519799+Robin5605@users.noreply.github.com> Date: Mon, 15 Aug 2022 17:51:04 -0500 Subject: Update proper-error-handling.md --- .../content/resources/guides/python-guides/proper-error-handling.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md index 9307169d..e0606625 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md +++ b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md @@ -16,7 +16,7 @@ class MyBot(commands.Bot): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + async def on_command_error(self, ctx: commands.Context, error): # Handle your errors here if isinstance(error, commands.MemberNotFound): @@ -28,7 +28,7 @@ class MyBot(commands.Bot): # All unhandled errors will print their original traceback print(f'Ignoring exception in command {ctx.command}:', file=sys.stderr) traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) - + bot = MyBot(command_prefix="!", intents=discord.Intents.default()) bot.run("token") @@ -65,6 +65,6 @@ Make sure to import `traceback` and `sys`! ------------------------------------------------------------------------------------------------------------- -Useful Links: +Useful Links: - [FAQ](https://discordpy.readthedocs.io/en/latest/faq.html) - [Simple Error Handling](https://gist.github.com/EvieePy/7822af90858ef65012ea500bcecf1612) -- cgit v1.2.3 From 5dfe019745b53ceb8ce37f0db937d6e2a302f6d7 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 16 Aug 2022 18:58:29 +0400 Subject: Move GitHub strptime Format To Settings Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/github_utils.py | 4 +--- pydis_site/settings.py | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py index 5d7bcdc3..e9d7347b 100644 --- a/pydis_site/apps/api/github_utils.py +++ b/pydis_site/apps/api/github_utils.py @@ -11,8 +11,6 @@ from pydis_site import settings MAX_RUN_TIME = datetime.timedelta(minutes=10) """The maximum time allowed before an action is declared timed out.""" -ISO_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ" -"""The datetime string format GitHub uses.""" class ArtifactProcessingError(Exception): @@ -147,7 +145,7 @@ def authorize(owner: str, repo: str) -> httpx.Client: def check_run_status(run: WorkflowRun) -> str: """Check if the provided run has been completed, otherwise raise an exception.""" - created_at = datetime.datetime.strptime(run.created_at, ISO_FORMAT_STRING) + created_at = datetime.datetime.strptime(run.created_at, settings.GITHUB_TIMESTAMP_FORMAT) run_time = datetime.datetime.utcnow() - created_at if run.status != "completed": diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 315ea737..9fbd0273 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -38,6 +38,8 @@ GITHUB_API = "https://api.github.com" GITHUB_TOKEN = env("GITHUB_TOKEN") GITHUB_APP_ID = env("GITHUB_APP_ID") GITHUB_APP_KEY = env("GITHUB_APP_KEY") +GITHUB_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +"""The datetime string format GitHub uses.""" if GITHUB_APP_KEY and (key_file := Path(GITHUB_APP_KEY)).is_file(): # Allow the OAuth key to be loaded from a file -- cgit v1.2.3 From f2374900c4c83097c105b56de02ea82d66bd9466 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 16 Aug 2022 21:19:53 +0400 Subject: Unify Tag Migrations & Add Commit Model Signed-off-by: Hassan Abouelela --- .../apps/content/migrations/0001_add_tags.py | 34 ++++++++++++++++++++ pydis_site/apps/content/migrations/0001_initial.py | 23 -------------- .../migrations/0002_remove_tag_url_tag_group.py | 22 ------------- pydis_site/apps/content/models/__init__.py | 4 +-- pydis_site/apps/content/models/tag.py | 36 ++++++++++++++++++++++ 5 files changed, 72 insertions(+), 47 deletions(-) create mode 100644 pydis_site/apps/content/migrations/0001_add_tags.py delete mode 100644 pydis_site/apps/content/migrations/0001_initial.py delete mode 100644 pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/migrations/0001_add_tags.py b/pydis_site/apps/content/migrations/0001_add_tags.py new file mode 100644 index 00000000..2e9d8c45 --- /dev/null +++ b/pydis_site/apps/content/migrations/0001_add_tags.py @@ -0,0 +1,34 @@ +# Generated by Django 4.0.6 on 2022-08-16 16:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Commit', + fields=[ + ('sha', models.CharField(help_text='The SHA hash of this commit.', max_length=40, primary_key=True, serialize=False)), + ('message', models.TextField(help_text='The commit message.')), + ('date', models.DateTimeField(help_text='The date and time the commit was created.')), + ('author', models.TextField(help_text='The person(s) who created the commit.')), + ], + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('last_updated', models.DateTimeField(auto_now=True, help_text='The date and time this data was last fetched.')), + ('name', models.CharField(help_text="The tag's name.", max_length=50, primary_key=True, serialize=False)), + ('group', models.CharField(help_text='The group the tag belongs to.', max_length=50, null=True)), + ('body', models.TextField(help_text='The content of the tag.')), + ('last_commit', models.OneToOneField(help_text='The commit this file was last touched in.', null=True, on_delete=django.db.models.deletion.CASCADE, to='content.commit')), + ], + ), + ] diff --git a/pydis_site/apps/content/migrations/0001_initial.py b/pydis_site/apps/content/migrations/0001_initial.py deleted file mode 100644 index 15e3fc95..00000000 --- a/pydis_site/apps/content/migrations/0001_initial.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.0.6 on 2022-08-13 00:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Tag', - fields=[ - ('last_updated', models.DateTimeField(auto_now=True, help_text='The date and time this data was last fetched.')), - ('name', models.CharField(help_text="The tag's name.", max_length=50, primary_key=True, serialize=False)), - ('body', models.TextField(help_text='The content of the tag.')), - ('url', models.URLField(help_text='The URL to this tag on GitHub.')), - ], - ), - ] diff --git a/pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py b/pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py deleted file mode 100644 index e59077f0..00000000 --- a/pydis_site/apps/content/migrations/0002_remove_tag_url_tag_group.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.0.6 on 2022-08-13 23:48 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('content', '0001_initial'), - ] - - operations = [ - migrations.RemoveField( - model_name='tag', - name='url', - ), - migrations.AddField( - model_name='tag', - name='group', - field=models.CharField(help_text='The group the tag belongs to.', max_length=50, null=True), - ), - ] diff --git a/pydis_site/apps/content/models/__init__.py b/pydis_site/apps/content/models/__init__.py index 2718ce94..60007e27 100644 --- a/pydis_site/apps/content/models/__init__.py +++ b/pydis_site/apps/content/models/__init__.py @@ -1,3 +1,3 @@ -from .tag import Tag +from .tag import Commit, Tag -__all__ = ["Tag"] +__all__ = ["Commit", "Tag"] diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py index 01264ff1..1c89fe1e 100644 --- a/pydis_site/apps/content/models/tag.py +++ b/pydis_site/apps/content/models/tag.py @@ -1,6 +1,36 @@ +import json + from django.db import models +class Commit(models.Model): + """A git commit.""" + + URL_BASE = "https://github.com/python-discord/bot/commit/" + + sha = models.CharField( + help_text="The SHA hash of this commit.", + primary_key=True, + max_length=40, + ) + message = models.TextField(help_text="The commit message.") + date = models.DateTimeField(help_text="The date and time the commit was created.") + author = models.TextField(help_text="The person(s) who created the commit.") + + @property + def url(self) -> str: + """The URL to the commit on GitHub.""" + return self.URL_BASE + self.sha + + @property + def format_users(self) -> str: + """Return a nice representation of the user(s)' name and email.""" + authors = [] + for author in json.loads(self.author): + authors.append(f"{author['name']} <{author['email']}>") + return ", ".join(authors) + + class Tag(models.Model): """A tag from the python-discord server.""" @@ -10,6 +40,12 @@ class Tag(models.Model): help_text="The date and time this data was last fetched.", auto_now=True, ) + last_commit = models.OneToOneField( + Commit, + help_text="The commit this file was last touched in.", + null=True, + on_delete=models.CASCADE, + ) name = models.CharField( help_text="The tag's name.", primary_key=True, -- cgit v1.2.3 From 04babac2f281487adcddbf1e92d9d028896e086e Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 16 Aug 2022 21:21:59 +0400 Subject: Add Tag Metadata Uses the commit API to obtain tag metadata such as when it was last edited, and by whom. Signed-off-by: Hassan Abouelela --- .../apps/content/migrations/0001_add_tags.py | 5 +- pydis_site/apps/content/models/tag.py | 14 ++- pydis_site/apps/content/utils.py | 124 ++++++++++++++++++--- pydis_site/static/css/content/tag.css | 6 +- pydis_site/templates/content/tag.html | 22 +++- 5 files changed, 146 insertions(+), 25 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/migrations/0001_add_tags.py b/pydis_site/apps/content/migrations/0001_add_tags.py index 2e9d8c45..73525243 100644 --- a/pydis_site/apps/content/migrations/0001_add_tags.py +++ b/pydis_site/apps/content/migrations/0001_add_tags.py @@ -1,4 +1,4 @@ -# Generated by Django 4.0.6 on 2022-08-16 16:17 +# Generated by Django 4.0.6 on 2022-08-16 17:38 import django.db.models.deletion from django.db import migrations, models @@ -25,10 +25,11 @@ class Migration(migrations.Migration): name='Tag', fields=[ ('last_updated', models.DateTimeField(auto_now=True, help_text='The date and time this data was last fetched.')), + ('sha', models.CharField(help_text="The tag's hash, as calculated by GitHub.", max_length=40)), ('name', models.CharField(help_text="The tag's name.", max_length=50, primary_key=True, serialize=False)), ('group', models.CharField(help_text='The group the tag belongs to.', max_length=50, null=True)), ('body', models.TextField(help_text='The content of the tag.')), - ('last_commit', models.OneToOneField(help_text='The commit this file was last touched in.', null=True, on_delete=django.db.models.deletion.CASCADE, to='content.commit')), + ('last_commit', models.ForeignKey(help_text='The commit this file was last touched in.', null=True, on_delete=django.db.models.deletion.CASCADE, to='content.commit')), ], ), ] diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py index 1c89fe1e..3c729768 100644 --- a/pydis_site/apps/content/models/tag.py +++ b/pydis_site/apps/content/models/tag.py @@ -1,3 +1,4 @@ +import collections.abc import json from django.db import models @@ -22,13 +23,10 @@ class Commit(models.Model): """The URL to the commit on GitHub.""" return self.URL_BASE + self.sha - @property - def format_users(self) -> str: + def format_users(self) -> collections.abc.Iterable[str]: """Return a nice representation of the user(s)' name and email.""" - authors = [] for author in json.loads(self.author): - authors.append(f"{author['name']} <{author['email']}>") - return ", ".join(authors) + yield f"{author['name']} <{author['email']}>" class Tag(models.Model): @@ -40,7 +38,11 @@ class Tag(models.Model): help_text="The date and time this data was last fetched.", auto_now=True, ) - last_commit = models.OneToOneField( + sha = models.CharField( + help_text="The tag's hash, as calculated by GitHub.", + max_length=40, + ) + last_commit = models.ForeignKey( Commit, help_text="The commit this file was last touched in.", null=True, diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index 11100ba5..7b078de6 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -1,5 +1,6 @@ import datetime import functools +import json import tarfile import tempfile import typing @@ -15,11 +16,26 @@ from django.utils import timezone from markdown.extensions.toc import TocExtension from pydis_site import settings -from .models import Tag +from .models import Commit, Tag TAG_CACHE_TTL = datetime.timedelta(hours=1) +def github_client(**kwargs) -> httpx.Client: + """Get a client to access the GitHub API with important settings pre-configured.""" + client = httpx.Client( + base_url=settings.GITHUB_API, + follow_redirects=True, + timeout=settings.TIMEOUT_PERIOD, + **kwargs + ) + if settings.GITHUB_TOKEN: # pragma: no cover + if not client.headers.get("Authorization"): + client.headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"} + + return client + + def get_category(path: Path) -> dict[str, str]: """Load category information by name from _info.yml.""" if not path.is_dir(): @@ -60,19 +76,31 @@ def fetch_tags() -> list[Tag]: The entire repository is downloaded and extracted locally because getting file content would require one request per file, and can get rate-limited. """ - if settings.GITHUB_TOKEN: # pragma: no cover - headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"} - else: - headers = {} + client = github_client() + + # Grab metadata + metadata = client.get("/repos/python-discord/bot/contents/bot/resources") + metadata.raise_for_status() + + hashes = {} + for entry in metadata.json(): + if entry["type"] == "dir": + # Tag group + files = client.get(entry["url"]) + files.raise_for_status() + files = files.json() + else: + files = [entry] - tar_file = httpx.get( - f"{settings.GITHUB_API}/repos/python-discord/bot/tarball", - follow_redirects=True, - timeout=settings.TIMEOUT_PERIOD, - headers=headers, - ) + for file in files: + hashes[file["name"]] = file["sha"] + + # Download the files + tar_file = client.get("/repos/python-discord/bot/tarball") tar_file.raise_for_status() + client.close() + tags = [] with tempfile.TemporaryDirectory() as folder: with tarfile.open(fileobj=BytesIO(tar_file.content)) as repo: @@ -83,20 +111,83 @@ def fetch_tags() -> list[Tag]: repo.extractall(folder, included) for tag_file in Path(folder).rglob("*.md"): + name = tag_file.name group = None if tag_file.parent.name != "tags": # Tags in sub-folders are considered part of a group group = tag_file.parent.name tags.append(Tag( - name=tag_file.name.removesuffix(".md"), + name=name.removesuffix(".md"), + sha=hashes[name], group=group, body=tag_file.read_text(encoding="utf-8"), + last_commit=None, )) return tags +def set_tag_commit(tag: Tag) -> Tag: + """Fetch commit information from the API, and save it for the tag.""" + path = "/bot/resources/tags" + if tag.group: + path += f"/{tag.group}" + path += f"/{tag.name}.md" + + # Fetch and set the commit + with github_client() as client: + data = client.get("/repos/python-discord/bot/commits", params={"path": path}) + data.raise_for_status() + data = data.json()[0] + + commit = data["commit"] + author, committer = commit["author"], commit["committer"] + + date = datetime.datetime.strptime(committer["date"], settings.GITHUB_TIMESTAMP_FORMAT) + date = date.replace(tzinfo=datetime.timezone.utc) + + if author["email"] == committer["email"]: + commit_author = [author] + else: + commit_author = [author, committer] + + commit_obj, _ = Commit.objects.get_or_create( + sha=data["sha"], + message=commit["message"], + date=date, + author=json.dumps(commit_author), + ) + tag.last_commit = commit_obj + tag.save() + + return tag + + +def record_tags(tags: list[Tag]) -> None: + """Sync the database with an updated set of tags.""" + # Remove entries which no longer exist + Tag.objects.exclude(name__in=[tag.name for tag in tags]).delete() + + # Insert/update the tags + for tag in tags: + try: + old_tag = Tag.objects.get(name=tag.name) + except Tag.DoesNotExist: + # The tag is not in the database yet, + # pretend it's previous state is the current state + old_tag = tag + + if old_tag.sha == tag.sha and old_tag.last_commit is not None: + # We still have an up-to-date commit entry + tag.last_commit = old_tag.last_commit + + tag.save() + + # Drop old, unused commits + Commit.objects.filter(tag__isnull=True).delete() + + def get_tags() -> list[Tag]: """Return a list of all tags visible to the application, from the cache or API.""" if settings.STATIC_BUILD: # pragma: no cover @@ -113,9 +204,7 @@ def get_tags() -> list[Tag]: tags = get_tags_static() else: tags = fetch_tags() - Tag.objects.exclude(name__in=[tag.name for tag in tags]).delete() - for tag in tags: - tag.save() + record_tags(tags) return tags else: @@ -127,6 +216,9 @@ def get_tag(path: str) -> typing.Union[Tag, list[Tag]]: """ Return a tag based on the search location. + If certain tag data is out of sync (for instance a commit date is missing), + an extra request will be made to sync the information. + The tag name and group must match. If only one argument is provided in the path, it's assumed to either be a group name, or a no-group tag name. @@ -142,6 +234,8 @@ def get_tag(path: str) -> typing.Union[Tag, list[Tag]]: matches = [] for tag in get_tags(): if tag.name == name and tag.group == group: + if tag.last_commit is None: + set_tag_commit(tag) return tag elif tag.group == name and group is None: matches.append(tag) diff --git a/pydis_site/static/css/content/tag.css b/pydis_site/static/css/content/tag.css index 32a605a8..79795f9e 100644 --- a/pydis_site/static/css/content/tag.css +++ b/pydis_site/static/css/content/tag.css @@ -5,5 +5,9 @@ } .content a *:hover { - color: black; + color: dimgray; +} + +span.update-time { + text-decoration: black underline dotted; } diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html index 9bd65744..513009da 100644 --- a/pydis_site/templates/content/tag.html +++ b/pydis_site/templates/content/tag.html @@ -9,10 +9,30 @@ {% endblock %} {% block title_element %} -
      + + + {% endblock %} -- cgit v1.2.3 From 7c240c68e24c0f3bf041522ce21de271cb92c6f3 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 16 Aug 2022 22:08:39 +0400 Subject: Better Split Up Tag Commit Messages Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/models/tag.py | 5 +++++ pydis_site/templates/content/tag.html | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py index 3c729768..c504ce21 100644 --- a/pydis_site/apps/content/models/tag.py +++ b/pydis_site/apps/content/models/tag.py @@ -23,6 +23,11 @@ class Commit(models.Model): """The URL to the commit on GitHub.""" return self.URL_BASE + self.sha + def lines(self) -> collections.abc.Iterable[str]: + """Return each line in the commit message.""" + for line in self.message.split("\n"): + yield line + def format_users(self) -> collections.abc.Iterable[str]: """Return a nice representation of the user(s)' name and email.""" for author in json.loads(self.author): diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html index 513009da..655dd786 100644 --- a/pydis_site/templates/content/tag.html +++ b/pydis_site/templates/content/tag.html @@ -31,7 +31,9 @@ {% endfor %} - + {% for line in tag.last_commit.lines %} + + {% endfor %}
      -- cgit v1.2.3 From 89b0853245fdf5ba7f1f386d7ea7ab1548b538da Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 16 Aug 2022 22:09:14 +0400 Subject: Fix Tag Metadata For Static Builds Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/utils.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index 7b078de6..e4a24a73 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -128,8 +128,19 @@ def fetch_tags() -> list[Tag]: return tags -def set_tag_commit(tag: Tag) -> Tag: +def set_tag_commit(tag: Tag) -> None: """Fetch commit information from the API, and save it for the tag.""" + if settings.STATIC_BUILD: + # Static builds request every page during build, which can ratelimit it. + # Instead, we return some fake data. + tag.last_commit = Commit( + sha="68da80efc00d9932a209d5cccd8d344cec0f09ea", + message="Initial Commit\n\nTHIS IS FAKE DEMO DATA", + date=datetime.datetime(2018, 2, 3, 12, 20, 26, tzinfo=datetime.timezone.utc), + author=json.dumps([{"name": "Joseph", "email": "joseph@josephbanks.me"}]), + ) + return + path = "/bot/resources/tags" if tag.group: path += f"/{tag.group}" @@ -161,8 +172,6 @@ def set_tag_commit(tag: Tag) -> Tag: tag.last_commit = commit_obj tag.save() - return tag - def record_tags(tags: list[Tag]) -> None: """Sync the database with an updated set of tags.""" -- cgit v1.2.3 From 92a42694b6ad1a29e5a21e0b3e57639528837113 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 16 Aug 2022 23:45:25 +0400 Subject: Fix Tests For Tag Metadata Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/tests/test_github_utils.py | 7 +- pydis_site/apps/content/tests/test_utils.py | 132 +++++++++++++++++++++++-- pydis_site/apps/content/tests/test_views.py | 36 ++++--- pydis_site/apps/content/utils.py | 2 +- 4 files changed, 148 insertions(+), 29 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py index f642f689..6e25bc80 100644 --- a/pydis_site/apps/api/tests/test_github_utils.py +++ b/pydis_site/apps/api/tests/test_github_utils.py @@ -11,6 +11,7 @@ import rest_framework.response import rest_framework.test from django.urls import reverse +from pydis_site import settings from .. import github_utils @@ -49,7 +50,7 @@ class CheckRunTests(unittest.TestCase): "head_sha": "sha", "status": "completed", "conclusion": "success", - "created_at": datetime.datetime.utcnow().strftime(github_utils.ISO_FORMAT_STRING), + "created_at": datetime.datetime.utcnow().strftime(settings.GITHUB_TIMESTAMP_FORMAT), "artifacts_url": "url", } @@ -74,7 +75,7 @@ class CheckRunTests(unittest.TestCase): # to guarantee the right conclusion kwargs["created_at"] = ( datetime.datetime.utcnow() - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10) - ).strftime(github_utils.ISO_FORMAT_STRING) + ).strftime(settings.GITHUB_TIMESTAMP_FORMAT) with self.assertRaises(github_utils.RunTimeoutError): github_utils.check_run_status(github_utils.WorkflowRun(**kwargs)) @@ -178,7 +179,7 @@ class ArtifactFetcherTests(unittest.TestCase): run = github_utils.WorkflowRun( name="action_name", head_sha="action_sha", - created_at=datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING), + created_at=datetime.datetime.now().strftime(settings.GITHUB_TIMESTAMP_FORMAT), status="completed", conclusion="success", artifacts_url="artifacts_url" diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py index 556f633c..2ef033e4 100644 --- a/pydis_site/apps/content/tests/test_utils.py +++ b/pydis_site/apps/content/tests/test_utils.py @@ -1,3 +1,5 @@ +import datetime +import json import tarfile import tempfile import textwrap @@ -15,6 +17,18 @@ from pydis_site.apps.content.tests.helpers import ( BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA ) +_time = datetime.datetime(2022, 10, 10, 10, 10, 10, tzinfo=datetime.timezone.utc) +_time_str = _time.strftime(settings.GITHUB_TIMESTAMP_FORMAT) +TEST_COMMIT_KWARGS = { + "sha": "123", + "message": "Hello world\n\nThis is a commit message", + "date": _time, + "author": json.dumps([ + {"name": "Author 1", "email": "mail1@example.com", "date": _time_str}, + {"name": "Author 2", "email": "mail2@example.com", "date": _time_str}, + ]), +} + class GetCategoryTests(MockPagesTestCase): """Tests for the get_category function.""" @@ -109,6 +123,10 @@ class GetPageTests(MockPagesTestCase): class TagUtilsTests(TestCase): """Tests for the tag-related utilities.""" + def setUp(self) -> None: + super().setUp() + self.commit = models.Commit.objects.create(**TEST_COMMIT_KWARGS) + @mock.patch.object(utils, "fetch_tags") def test_static_fetch(self, fetch_mock: mock.Mock): """Test that the static fetch function is only called at most once during static builds.""" @@ -121,9 +139,27 @@ class TagUtilsTests(TestCase): self.assertEqual(tags, result) self.assertEqual(tags, second_result) - @mock.patch("httpx.get") + @mock.patch("httpx.Client.get") def test_mocked_fetch(self, get_mock: mock.Mock): """Test that proper data is returned from fetch, but with a mocked API response.""" + fake_request = httpx.Request("GET", "https://google.com") + + # Metadata requests + returns = [httpx.Response( + request=fake_request, + status_code=200, + json=[ + {"type": "file", "name": "first_tag.md", "sha": "123"}, + {"type": "file", "name": "second_tag.md", "sha": "456"}, + {"type": "dir", "name": "some_group", "sha": "789", "url": "/some_group"}, + ] + ), httpx.Response( + request=fake_request, + status_code=200, + json=[{"type": "file", "name": "grouped_tag.md", "sha": "789123"}] + )] + + # Main content request bodies = ( "This is the first tag!", textwrap.dedent(""" @@ -156,33 +192,36 @@ class TagUtilsTests(TestCase): body = (tar_folder / "temp.tar").read_bytes() - get_mock.return_value = httpx.Response( + returns.append(httpx.Response( status_code=200, content=body, - request=httpx.Request("GET", "https://google.com"), - ) + request=fake_request, + )) + get_mock.side_effect = returns result = utils.fetch_tags() def sort(_tag: models.Tag) -> str: return _tag.name self.assertEqual(sorted([ - models.Tag(name="first_tag", body=bodies[0]), - models.Tag(name="second_tag", body=bodies[1]), - models.Tag(name="grouped_tag", body=bodies[2], group=group_folder.name), + models.Tag(name="first_tag", body=bodies[0], sha="123"), + models.Tag(name="second_tag", body=bodies[1], sha="245"), + models.Tag(name="grouped_tag", body=bodies[2], group=group_folder.name, sha="789123"), ], key=sort), sorted(result, key=sort)) def test_get_real_tag(self): """Test that a single tag is returned if it exists.""" - tag = models.Tag.objects.create(name="real-tag") + tag = models.Tag.objects.create(name="real-tag", last_commit=self.commit) result = utils.get_tag("real-tag") self.assertEqual(tag, result) def test_get_grouped_tag(self): """Test fetching a tag from a group.""" - tag = models.Tag.objects.create(name="real-tag", group="real-group") + tag = models.Tag.objects.create( + name="real-tag", group="real-group", last_commit=self.commit + ) result = utils.get_tag("real-group/real-tag") self.assertEqual(tag, result) @@ -269,3 +308,78 @@ class TagUtilsTests(TestCase): tag = models.Tag(**options) with self.subTest(tag=tag): self.assertEqual(url, tag.url) + + @mock.patch("httpx.Client.get") + def test_get_tag_commit(self, get_mock: mock.Mock): + """Test the get commit function with a normal tag.""" + tag = models.Tag.objects.create(name="example") + + authors = json.loads(self.commit.author) + + get_mock.return_value = httpx.Response( + request=httpx.Request("GET", "https://google.com"), + status_code=200, + json=[{ + "sha": self.commit.sha, + "commit": { + "message": self.commit.message, + "author": authors[0], + "committer": authors[1], + } + }] + ) + + result = utils.get_tag(tag.name) + self.assertEqual(tag, result) + + get_mock.assert_called_once() + call_params = get_mock.call_args[1]["params"] + + self.assertEqual({"path": "/bot/resources/tags/example.md"}, call_params) + self.assertEqual(self.commit, models.Tag.objects.get(name=tag.name).last_commit) + + @mock.patch("httpx.Client.get") + def test_get_group_tag_commit(self, get_mock: mock.Mock): + """Test the get commit function with a group tag.""" + tag = models.Tag.objects.create(name="example", group="group-name") + + authors = json.loads(self.commit.author) + authors.pop() + self.commit.author = json.dumps(authors) + self.commit.save() + + get_mock.return_value = httpx.Response( + request=httpx.Request("GET", "https://google.com"), + status_code=200, + json=[{ + "sha": self.commit.sha, + "commit": { + "message": self.commit.message, + "author": authors[0], + "committer": authors[0], + } + }] + ) + + utils.set_tag_commit(tag) + + get_mock.assert_called_once() + call_params = get_mock.call_args[1]["params"] + + self.assertEqual({"path": "/bot/resources/tags/group-name/example.md"}, call_params) + self.assertEqual(self.commit, models.Tag.objects.get(name=tag.name).last_commit) + + @mock.patch.object(utils, "set_tag_commit") + def test_exiting_commit(self, set_commit_mock: mock.Mock): + """Test that a commit is saved when the data has not changed.""" + tag = models.Tag.objects.create(name="tag-name", body="old body", last_commit=self.commit) + + # This is only applied to the object, not to the database + tag.last_commit = None + + utils.record_tags([tag]) + self.assertEqual(self.commit, tag.last_commit) + + result = utils.get_tag("tag-name") + self.assertEqual(tag, result) + set_commit_mock.assert_not_called() diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py index c5c25be4..658ac2cc 100644 --- a/pydis_site/apps/content/tests/test_views.py +++ b/pydis_site/apps/content/tests/test_views.py @@ -8,10 +8,11 @@ from django.http import Http404 from django.test import RequestFactory, SimpleTestCase, override_settings from django.urls import reverse -from pydis_site.apps.content.models import Tag +from pydis_site.apps.content.models import Commit, Tag from pydis_site.apps.content.tests.helpers import ( BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA ) +from pydis_site.apps.content.tests.test_utils import TEST_COMMIT_KWARGS from pydis_site.apps.content.views import PageOrCategoryView @@ -193,11 +194,12 @@ class TagViewTests(django.test.TestCase): def setUp(self): """Set test helpers, then set up fake filesystem.""" super().setUp() + self.commit = Commit.objects.create(**TEST_COMMIT_KWARGS) def test_routing(self): """Test that the correct template is returned for each route.""" - Tag.objects.create(name="example") - Tag.objects.create(name="grouped-tag", group="group-name") + Tag.objects.create(name="example", last_commit=self.commit) + Tag.objects.create(name="grouped-tag", group="group-name", last_commit=self.commit) cases = [ ("/pages/tags/example/", "content/tag.html"), @@ -213,7 +215,7 @@ class TagViewTests(django.test.TestCase): def test_valid_tag_returns_200(self): """Test that a page is returned for a valid tag.""" - Tag.objects.create(name="example", body="This is the tag body.") + Tag.objects.create(name="example", body="This is the tag body.", last_commit=self.commit) response = self.client.get("/pages/tags/example/") self.assertEqual(200, response.status_code) self.assertIn("This is the tag body", response.content.decode("utf-8")) @@ -233,7 +235,7 @@ class TagViewTests(django.test.TestCase): Tag content here. """) - tag = Tag.objects.create(name="example", body=body) + tag = Tag.objects.create(name="example", body=body, last_commit=self.commit) response = self.client.get("/pages/tags/example/") expected = { "page_title": "example", @@ -256,7 +258,9 @@ class TagViewTests(django.test.TestCase): The only difference between this and a regular tag are the breadcrumbs, so only those are checked. """ - Tag.objects.create(name="example", body="Body text", group="group-name") + Tag.objects.create( + name="example", body="Body text", group="group-name", last_commit=self.commit + ) response = self.client.get("/pages/tags/group-name/example/") self.assertListEqual([ {"name": "Pages", "path": "."}, @@ -266,9 +270,9 @@ class TagViewTests(django.test.TestCase): def test_group_page(self): """Test rendering of a group's root page.""" - Tag.objects.create(name="tag-1", body="Body 1", group="group-name") - Tag.objects.create(name="tag-2", body="Body 2", group="group-name") - Tag.objects.create(name="not-included") + Tag.objects.create(name="tag-1", body="Body 1", group="group-name", last_commit=self.commit) + Tag.objects.create(name="tag-2", body="Body 2", group="group-name", last_commit=self.commit) + Tag.objects.create(name="not-included", last_commit=self.commit) response = self.client.get("/pages/tags/group-name/") content = response.content.decode("utf-8") @@ -298,7 +302,7 @@ class TagViewTests(django.test.TestCase): **This text is in bold** """) - Tag.objects.create(name="example", body=body) + Tag.objects.create(name="example", body=body, last_commit=self.commit) response = self.client.get("/pages/tags/example/") content = response.content.decode("utf-8") @@ -317,7 +321,7 @@ class TagViewTests(django.test.TestCase): Tag body. """) - Tag.objects.create(name="example", body=body) + Tag.objects.create(name="example", body=body, last_commit=self.commit) response = self.client.get("/pages/tags/example/") content = response.content.decode("utf-8") @@ -333,7 +337,7 @@ class TagViewTests(django.test.TestCase): --- """) - Tag.objects.create(name="example", body=body) + Tag.objects.create(name="example", body=body, last_commit=self.commit) response = self.client.get("/pages/tags/example/") self.assertEqual( "Embed title", @@ -345,7 +349,7 @@ class TagViewTests(django.test.TestCase): """Test hyperlinking of tags works as intended.""" filler_before, filler_after = "empty filler text\n\n", "more\nfiller" body = filler_before + "`!tags return`" + filler_after - Tag.objects.create(name="example", body=body) + Tag.objects.create(name="example", body=body, last_commit=self.commit) other_url = reverse("content:tag", kwargs={"location": "return"}) response = self.client.get("/pages/tags/example/") @@ -356,9 +360,9 @@ class TagViewTests(django.test.TestCase): def test_tag_root_page(self): """Test the root tag page which lists all tags.""" - Tag.objects.create(name="tag-1") - Tag.objects.create(name="tag-2") - Tag.objects.create(name="tag-3") + Tag.objects.create(name="tag-1", last_commit=self.commit) + Tag.objects.create(name="tag-2", last_commit=self.commit) + Tag.objects.create(name="tag-3", last_commit=self.commit) response = self.client.get("/pages/tags/") content = response.content.decode("utf-8") diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index e4a24a73..63f1c41c 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -130,7 +130,7 @@ def fetch_tags() -> list[Tag]: def set_tag_commit(tag: Tag) -> None: """Fetch commit information from the API, and save it for the tag.""" - if settings.STATIC_BUILD: + if settings.STATIC_BUILD: # pragma: no cover # Static builds request every page during build, which can ratelimit it. # Instead, we return some fake data. tag.last_commit = Commit( -- cgit v1.2.3 From c0823236d20e801550fccdbb021d8aabb56d59c0 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Thu, 18 Aug 2022 16:58:08 +0100 Subject: add collection of keywords per rule In reference to issue #2108, this commit aims to add an initial set of keywords per rule. These keywords will be later in the "rule" bot command in order to make rule identification easier --- pydis_site/apps/api/views.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index ad2d948e..f96d6a8d 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -124,35 +124,44 @@ class RulesView(APIView): return Response([ ( - f"Follow the {pydis_coc}." + f"Follow the {pydis_coc}.", + {"coc", "conduct", "code"} ), ( - f"Follow the {discord_community_guidelines} and {discord_tos}." + f"Follow the {discord_community_guidelines} and {discord_tos}.", + {"guidelines", "discord_tos"} ), ( - "Respect staff members and listen to their instructions." + "Respect staff members and listen to their instructions.", + {"staff", "instructions"} ), ( "Use English to the best of your ability. " - "Be polite if someone speaks English imperfectly." + "Be polite if someone speaks English imperfectly.", + {"english", "language"} ), ( "Do not provide or request help on projects that may break laws, " - "breach terms of services, or are malicious or inappropriate." + "breach terms of services, or are malicious or inappropriate.", + {"infraction", "tos", "breach", "malicious", "inappropriate"} ), ( - "Do not post unapproved advertising." + "Do not post unapproved advertising.", + {"ads", "advertising"} ), ( "Keep discussions relevant to the channel topic. " - "Each channel's description tells you the topic." + "Each channel's description tells you the topic.", + {"off-topic", "topic", "relevance"} ), ( "Do not help with ongoing exams. When helping with homework, " - "help people learn how to do the assignment without doing it for them." + "help people learn how to do the assignment without doing it for them.", + {"exams", "assignment", "assignments", "homework"} ), ( - "Do not offer or ask for paid work of any kind." + "Do not offer or ask for paid work of any kind.", + {"work", "money"} ), ]) -- cgit v1.2.3 From c0384c626121684ad4e354aeb817fdbd2741fc4f Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 23 Aug 2022 01:09:01 +0400 Subject: Improve Tag Commit Naming Signed-off-by: Hassan Abouelela --- pydis_site/apps/content/models/tag.py | 6 +++--- pydis_site/apps/content/urls.py | 2 +- pydis_site/templates/content/tag.html | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py index c504ce21..73d6cb79 100644 --- a/pydis_site/apps/content/models/tag.py +++ b/pydis_site/apps/content/models/tag.py @@ -5,7 +5,7 @@ from django.db import models class Commit(models.Model): - """A git commit.""" + """A git commit from the Python Discord Bot project.""" URL_BASE = "https://github.com/python-discord/bot/commit/" @@ -28,8 +28,8 @@ class Commit(models.Model): for line in self.message.split("\n"): yield line - def format_users(self) -> collections.abc.Iterable[str]: - """Return a nice representation of the user(s)' name and email.""" + def format_authors(self) -> collections.abc.Iterable[str]: + """Return a nice representation of the author(s)' name and email.""" for author in json.loads(self.author): yield f"{author['name']} <{author['email']}>" diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py index 03c0015a..163d05bc 100644 --- a/pydis_site/apps/content/urls.py +++ b/pydis_site/apps/content/urls.py @@ -63,5 +63,5 @@ urlpatterns = [ views.PageOrCategoryView.as_view(), name='page_category', distill_func=get_all_pages - ) + ), ] diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html index 655dd786..fa9e44f5 100644 --- a/pydis_site/templates/content/tag.html +++ b/pydis_site/templates/content/tag.html @@ -27,7 +27,7 @@
      -- cgit v1.2.3 From 7d372a3b12fd6cbff9b2d928ece6ad6f45c9bb2d Mon Sep 17 00:00:00 2001 From: Amrou Date: Sat, 25 Feb 2023 12:45:55 +0100 Subject: update recommendeations of values that need overriding --- .../guides/pydis-guides/contributing/bot.md | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 7ab26008..cec2ab63 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -549,22 +549,22 @@ filters.ping_everyone=true
      -If you don't wish to use the provided `config.yml` above, these are the main sections in `config-default.yml` that need overriding: -* `guild.id` -* `guild.categories` -* `guild.channels` -* `guild.roles` -* `guild.webhooks` -* `style.emojis` +If you don't wish to use the provided `env.server` above, the main values that need overriding are **all** the ones prefixed with: + +* `guild.` +* `categories.` +* `channels.` +* `roles.` +* `webhooks.` +* `emojis.` Additionally: -* At this stage, set `bot.redis.use_fakeredis` to `true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). -* Set `urls.site_api` to `!JOIN [*DOMAIN, "/api"]`. +* At this stage, set `redis.use_fakeredis` to `true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). * Set `urls.site_schema` and `urls.site_api_schema` to `"http://"`. -We understand this is tedious and are working on a better solution for setting up test servers. +We understand this is tedious which is why we **recommend** using the [automatic configuration setup](#automatic-configuration)
      -- cgit v1.2.3 From 281bbf34f9e25dde8af1e99cb7971348a589d689 Mon Sep 17 00:00:00 2001 From: Amrou Date: Sat, 25 Feb 2023 12:58:30 +0100 Subject: update all mentions of config & default-config.yaml --- .../resources/guides/pydis-guides/contributing/bot.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index cec2ab63..6f717b9b 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -615,7 +615,7 @@ You are now almost ready to run the Python bot. The simplest way to do so is wit
      -In your `config.yml` file: +In your `.env.server` file: * Set `urls.site` to `"web:8000"`. * If you wish to work with snekbox set the following: @@ -634,7 +634,7 @@ Your bot is now running, but this method makes debugging with an IDE a fairly in The advantage of this method is that you can run the bot's code in your preferred editor, with debugger and all, while keeping all the setup of the bot's various dependencies inside Docker. * Append the following line to your `.env` file: `BOT_API_KEY=badbot13m0n8f570f942013fc818f234916ca531`. -* In your `config.yml` file, set `urls.site` to `"localhost:8000"`. If you wish to keep using `web:8000`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. +* In your `.env.server` file, set `urls.site` to `"localhost:8000"`. If you wish to keep using `web:8000`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. * To work with snekbox, set `urls.snekbox_eval_api` to `"http://localhost:8060/eval"` and `urls.snekbox_311_eval_api` to `"http://localhost:8065/eval"` You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: @@ -679,7 +679,7 @@ You can run additional services on the host, but this guide won't go over how to If possible, prefer to start the services through Docker to replicate the production environment as much as possible. The site, however, is a mandatory service for the bot. -Refer to the [previous section](#with-the-bot-running-locally) and the [site contributing guide](../site) to learn how to start it on the host, in which case you will need to change `urls.site` in `config.yml` to wherever the site is being hosted. +Refer to the [previous section](#with-the-bot-running-locally) and the [site contributing guide](../site) to learn how to start it on the host, in which case you will need to change `urls.site` in `.env.server` to wherever the site is being hosted. --- ### Development Tips @@ -721,15 +721,15 @@ We are always open to more statistics so add as many as you can! --- ### Optional: Working with Redis -In [Configure the Bot](#configyml) you were asked to set `bot.redis.use_fakeredis` to `true`. If you do not need to work on features that rely on Redis, this is enough. Fakeredis will give the illusion that features relying on Redis are saving information properly, but restarting the bot or the specific cog will wipe that information. +In [Configure the Bot](#envserver) you were asked to set `redis.use_fakeredis` to `true`. If you do not need to work on features that rely on Redis, this is enough. Fakeredis will give the illusion that features relying on Redis are saving information properly, but restarting the bot or the specific cog will wipe that information. -If you are working on a feature that relies on Redis, you will need to enable Redis to make sure persistency is achieved for the feature across restarts. The first step towards that is going to `config.yml` and setting `bot.redis.use_fakeredis` to `false`. +If you are working on a feature that relies on Redis, you will need to enable Redis to make sure persistency is achieved for the feature across restarts. The first step towards that is going to `.env.server` and setting `redis.use_fakeredis` to `false`. #### Starting Redis in Docker (Recommended) -If you're using the Docker image provided in the project's Docker Compose, open your `config.yml` file. If you're running the bot in Docker, set `bot.redis.host` to `redis`, and if you're running it on the host set it to `localhost`. Set `bot.redis.password` to `null`. +If you're using the Docker image provided in the project's Docker Compose, open your `.env.server` file. If you're running the bot in Docker, set `redis.host` to `redis`, and if you're running it on the host set it to `localhost`. Set `bot.redis.password` to `""`. #### Starting Redis Using Other Methods -You can run your own instance of Redis, but in that case you will need to correctly set `bot.redis.host` and `bot.redis.port`, and the `bot.redis.password` value in `config-default.yml` should not be overridden. Then, enter the `.env` file, and set `REDIS_PASSWORD` to whatever password you set. +You can run your own instance of Redis, but in that case you will need to correctly set `redis.host` and `redis.port`, and the `redis.password` value in `constants.py` should not be overridden. Then, enter the `.env` file, and set `REDIS_PASSWORD` to whatever password you set. --- -- cgit v1.2.3 From 36ce014fe91553bd3ddd9411ff25be58209cd755 Mon Sep 17 00:00:00 2001 From: Amrou Date: Sat, 25 Feb 2023 13:00:56 +0100 Subject: update info of the `GUILD_ID` env var --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 6f717b9b..6d9be90e 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -768,7 +768,8 @@ The following is a list of all available environment variables used by the bot: | Variable | Required | Description | |----------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `BOT_TOKEN` | Always | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)). | -| `GUILD_ID` | Always | Your Discord test server's id (see [Set Up a Bot Account](#set-up-a-bot-account)). | +| `GUILD_ID` | When using the bootstrapping script | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)). | +| `GUILD_ID` | When using the bootstrapping script | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)). | | `BOT_API_KEY` | When running bot without Docker | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below. | | `BOT_SENTRY_DSN` | When connecting the bot to sentry | The DSN of the sentry monitor. | | `BOT_TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. | -- cgit v1.2.3 From 217d874ba0c66d3e2708e899863f34bee65ad9dd Mon Sep 17 00:00:00 2001 From: Amrou Date: Sat, 25 Feb 2023 13:04:24 +0100 Subject: remove duplicated line in appendix --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 1 - 1 file changed, 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 6d9be90e..a2b58443 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -769,7 +769,6 @@ The following is a list of all available environment variables used by the bot: |----------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `BOT_TOKEN` | Always | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)). | | `GUILD_ID` | When using the bootstrapping script | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)). | -| `GUILD_ID` | When using the bootstrapping script | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)). | | `BOT_API_KEY` | When running bot without Docker | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below. | | `BOT_SENTRY_DSN` | When connecting the bot to sentry | The DSN of the sentry monitor. | | `BOT_TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. | -- cgit v1.2.3 From a5e68bc4962a0c2e7b71a7f59cbdccf3b220725a Mon Sep 17 00:00:00 2001 From: Amrou Date: Sat, 25 Feb 2023 13:09:45 +0100 Subject: update guide links --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index a2b58443..0a7a116c 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -53,13 +53,13 @@ Inside, add the following two lines: BOT_TOKEN=YourDiscordBotTokenHere GUILD_ID=YourDiscordTestServerIdHere ``` -See [here](../creating-bot-account) for help with obtaining the bot token and [here](obtaining-discord-ids.md#guild-id) for help with obtaining the guild's id +See [here](../creating-bot-account) for help with obtaining the bot token and [here](../obtaining-discord-ids#guild-id) for help with obtaining the guild's id **Note**: The `.env` file will be ignored by commits. ##### 1.2 Setting up the script environment The bootstrapping script is Python code like any other. To run it locally, you will need the right version of Python with the necessary packages installed: 1. Make sure you follow steps `1` and `2` [here](#setting-up-a-development-environment) -2. [Install the `config-bootstrap` dependency group](installing-project-dependencies.md#installing-specific-dependency-groups). +2. [Install the `config-bootstrap` dependency group](../installing-project-dependencies#installing-specific-dependency-groups). #### 2. Running the script -- cgit v1.2.3 From 4364bad23223ae6e271d9d60ef30787be2be7d51 Mon Sep 17 00:00:00 2001 From: Amrou Date: Sat, 25 Feb 2023 13:11:50 +0100 Subject: update the run it link --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 0a7a116c..dba4b280 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -47,6 +47,7 @@ without having to spend much time copying ids from your newly created server int ##### 1. Script setup ##### 1.1. Environment variables You will need to create a file called `.env` which will contain two required values for the script to work: `BOT_TOKEN` and `GUILD_ID` + Inside, add the following two lines: ```text @@ -58,6 +59,7 @@ See [here](../creating-bot-account) for help with obtaining the bot token and [h **Note**: The `.env` file will be ignored by commits. ##### 1.2 Setting up the script environment The bootstrapping script is Python code like any other. To run it locally, you will need the right version of Python with the necessary packages installed: + 1. Make sure you follow steps `1` and `2` [here](#setting-up-a-development-environment) 2. [Install the `config-bootstrap` dependency group](../installing-project-dependencies#installing-specific-dependency-groups). @@ -78,7 +80,8 @@ python3 -m bootstrap_config Once the script has finished running, you'll notice the creation of a new file called [`.env.server`](#envserver) at your project's root directory. This file will contain the extracted ids from your newly created server which are necessary for your bot to run. -Congratulations, you have finished the configuration & can now start [running your bot](#run-it-) + +**Congratulations**, you have finished the configuration & can now start [running your bot](#run-it) #### Manual configuration ##### .env.server -- cgit v1.2.3 From 493adc94f96f7444e9b449795d397d2213a78788 Mon Sep 17 00:00:00 2001 From: Amrou Date: Sat, 25 Feb 2023 13:14:00 +0100 Subject: put constants.py inside code tags --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index dba4b280..709b57b3 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -578,7 +578,7 @@ We understand this is tedious which is why we **recommend** using the [automatic
      -- cgit v1.2.3 From 2af659981711cabf817f0f4b1805b4aa98ff69f2 Mon Sep 17 00:00:00 2001 From: Amrou Date: Sat, 25 Feb 2023 13:25:18 +0100 Subject: add old config comments --- .../resources/guides/pydis-guides/contributing/bot.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index a6d41bb6..eb87c183 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -155,6 +155,10 @@ See [here](../obtaining-discord-ids) for help with obtaining Discord IDs.
                       
       
      +##### <<  Replace the following � characters with the channel IDs in your test server  >> #####
      +#  This assumes the template was used: https://discord.new/zmHtscpYN9E3
      +
      +
       # Channels configuration
       channels.announcements=�
       channels.changelog=�
      @@ -232,6 +236,9 @@ channels.big_brother_logs=�
       channels.duck_pond=�
       channels.roles=�
       
      +##### << Replace the following � characters with the role IDs in your test server >> #####
      +
      +
       # Roles configuration
       
       roles.advent_of_code=�
      @@ -273,7 +280,7 @@ roles.patreon_tier_1=�
       roles.patreon_tier_2=�
       roles.patreon_tier_3=�
       
      -
      +##### << Replace the following � characters with the category IDs in your test server >> #####
       
       # Categories configuration
       
      @@ -287,12 +294,15 @@ categories.voice=�
       ### 2021 Summer Code Jam
       categories.summer_code_jam=�
       
      +##### << Replace the following � character with the ID of your test server >> #####
       
       # Guild configuration
       guild.id=�
       guild.invite="https://discord.gg/python"
       
       
      +##### << Replace the following � characters with the webhook IDs in your test server >> #####
      +
       # Webhooks configuration
       
       webhooks.big_brother.id=�
      @@ -435,6 +445,10 @@ urls.site_api_schema="http://"
       urls.connect_max_retries=3
       urls.connect_cooldown=5
       
      +##### << The bot shouldn't fail without these, but commands adding specific emojis won't work. >> #####
      +##### << You should at least set the trashcan. Set the incidents emojis if relevant. >> #####
      +
      +
       
       # Emojis configuration
       emojis.badge_bug_hunter="<:bug_hunter_lvl1:743882896372269137>"
      @@ -531,6 +545,8 @@ icons.voice_state_blue="https://cdn.discordapp.com/emojis/656899769662439456.png
       icons.voice_state_green="https://cdn.discordapp.com/emojis/656899770094452754.png"
       icons.voice_state_red="https://cdn.discordapp.com/emojis/656899769905709076.png"
       
      +##### << Optional - If you don't care about the filtering, help channel and py-news cogs, ignore the rest of this file >> #####
      +
       # Filters configuration
       filters.filter_domains=true
       filters.filter_everyone_ping=true
      -- 
      cgit v1.2.3
      
      
      From a82c4bfc0babdcf52cd67c59eebdc5b1e9c5d47d Mon Sep 17 00:00:00 2001
      From: Ibrahim 
      Date: Sun, 26 Feb 2023 00:01:28 +0530
      Subject: Update description of `TRASHCAN_EMOJI` according to review
      
      ---
       .../content/resources/guides/pydis-guides/contributing/sir-lancebot.md | 2 +-
       .../guides/pydis-guides/contributing/sir-lancebot/env-var-reference.md | 3 +--
       2 files changed, 2 insertions(+), 3 deletions(-)
      
      (limited to 'pydis_site/apps')
      
      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 9e48c624..8b97cf06 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
      @@ -81,7 +81,7 @@ The following variables are needed for running Sir Lancebot:
       | `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 |
      -| `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. |
       
       [**Full environment variable reference for this project.**](./env-var-reference)
       
      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 09ec9394..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
      @@ -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
      -- 
      cgit v1.2.3
      
      
      From e1100b443844d4e9935e661e63ba352d867fd647 Mon Sep 17 00:00:00 2001
      From: Amrou 
      Date: Sat, 25 Feb 2023 19:53:33 +0100
      Subject: remove usage of the Field class
      
      ---
       .../apps/content/resources/guides/pydis-guides/contributing/bot.md  | 6 ++----
       1 file changed, 2 insertions(+), 4 deletions(-)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index eb87c183..dbded02a 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -101,8 +101,6 @@ Let's take an example where we suppose we'll only be testing a feature that need
       
       ```py
       
      -from pydantic import Field
      -
       class EnvConfig:
           # Defines from where & how Pydantic will be looking for env variables
           ...
      @@ -111,8 +109,8 @@ class _Channels(EnvConfig):
       
           EnvConfig.Config.env_prefix = "channels."
       
      -    announcements: int = Field(default=123)
      -    changelog: int = Field(default=456)
      +    announcements = 123
      +    changelog = 456
       
       # Instantiate the class & load the configuration
       Channels = _Channels()
      -- 
      cgit v1.2.3
      
      
      From 67db760b8c05f9d2ec57930edfabedcb4ab11978 Mon Sep 17 00:00:00 2001
      From: Amrou 
      Date: Sun, 26 Feb 2023 01:18:26 +0100
      Subject: update appendix to use the "." separator
      
      ---
       .../guides/pydis-guides/contributing/bot.md        | 38 +++++++++++-----------
       1 file changed, 19 insertions(+), 19 deletions(-)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index dbded02a..83f541e1 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -46,13 +46,13 @@ without having to spend much time copying ids from your newly created server int
       
       ##### 1. Script setup
       ##### 1.1. Environment variables
      -You will need to create a file called `.env` which will contain two required values for the script to work: `BOT_TOKEN` and `GUILD_ID`
      +You will need to create a file called `.env` which will contain two required values for the script to work: `BOT.TOKEN` and `GUILD.ID`
       
       Inside, add the following two lines:
       
       ```text
      -BOT_TOKEN=YourDiscordBotTokenHere
      -GUILD_ID=YourDiscordTestServerIdHere
      +BOT.TOKEN=YourDiscordBotTokenHere
      +GUILD.ID=YourDiscordTestServerIdHere
       ```
       See [here](../creating-bot-account) for help with obtaining the bot token and [here](../obtaining-discord-ids#guild-id) for help with obtaining the guild's id
       
      @@ -604,7 +604,7 @@ We understand this is tedious which is why we **recommend** using the [automatic
       
       ##### .env
       The second file you need to create is the one containing the environment variables, and needs to be named `.env`.
      -Inside, add the line `BOT_TOKEN=YourDiscordBotTokenHere`. See [here](../creating-bot-account) for help with obtaining the bot token.
      +Inside, add the line `BOT.TOKEN=YourDiscordBotTokenHere`. See [here](../creating-bot-account) for help with obtaining the bot token.
       
       **Note**: The `.env` file will be ignored by commits.
       
      @@ -652,7 +652,7 @@ Your bot is now running, but this method makes debugging with an IDE a fairly in
       #### With the Bot Running Locally
       The advantage of this method is that you can run the bot's code in your preferred editor, with debugger and all, while keeping all the setup of the bot's various dependencies inside Docker.
       
      -* Append the following line to your `.env` file: `BOT_API_KEY=badbot13m0n8f570f942013fc818f234916ca531`.
      +* Append the following line to your `.env` file: `API_KEYS.SITE_API=badbot13m0n8f570f942013fc818f234916ca531`.
       * In your `.env.server` file, set `urls.site` to `"localhost:8000"`. If you wish to keep using `web:8000`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set.
       * To work with snekbox, set `urls.snekbox_eval_api` to `"http://localhost:8060/eval"` and `urls.snekbox_311_eval_api` to `"http://localhost:8065/eval"`
       
      @@ -748,7 +748,7 @@ If you are working on a feature that relies on Redis, you will need to enable Re
       If you're using the Docker image provided in the project's Docker Compose, open your `.env.server` file. If you're running the bot in Docker, set `redis.host` to `redis`, and if you're running it on the host set it to `localhost`. Set `bot.redis.password` to `""`.
       
       #### Starting Redis Using Other Methods
      -You can run your own instance of Redis, but in that case you will need to correctly set `redis.host` and `redis.port`, and the `redis.password` value in `constants.py` should not be overridden. Then, enter the `.env` file, and set `REDIS_PASSWORD` to whatever password you set.
      +You can run your own instance of Redis, but in that case you will need to correctly set `redis.host` and `redis.port`, and the `redis.password` value in `constants.py` should not be overridden. Then, enter the `.env` file, and set `REDIS.PASSWORD` to whatever password you set.
       
       ---
       
      @@ -784,19 +784,19 @@ If you find any bugs in the bot or would like to request a feature, feel free to
       ### Appendix: Full ENV File Options
       The following is a list of all available environment variables used by the bot:
       
      -| Variable             | Required                                        | Description                                                                                                                                                                                                                                         |
      -|----------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
      -| `BOT_TOKEN`          | Always                                          | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)).                                                                                                                                                               |
      -| `GUILD_ID`           | When using the bootstrapping script             | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)).                                                                                                                                                                  |
      -| `BOT_API_KEY`        | When running bot without Docker                 | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below.                                                                |
      -| `BOT_SENTRY_DSN`     | When connecting the bot to sentry               | The DSN of the sentry monitor.                                                                                                                                                                                                                      |
      -| `BOT_TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. |
      -| `BOT_DEBUG`          | In production                                   | `true` or `false`, depending on whether to enable debug mode, affecting the behavior of certain features. `true` by default.                                                                                                                        |
      -| `REDIS_PASSWORD`     | When not using FakeRedis                        | The password to connect to the Redis database (see [Optional: Working with Redis](#optional-working-with-redis)).                                                                                                                                   |
      -| `USE_METRICITY`      | When using Metricity                            | `true` or `false`, depending on whether to enable metrics collection using Metricity (see [Optional: Working with Metricity](#optional-working-with-metricity)). `false` by default.                                                                |
      -| `GITHUB_API_KEY`     | When you wish to interact with GitHub           | The API key to interact with GitHub, for example to download files for the branding manager.                                                                                                                                                        |
      -| `METABASE_USERNAME`  | When you wish to interact with Metabase         | The username for a Metabase admin account.                                                                                                                                                                                                          |
      -| `METABASE_PASSWORD`  | When you wish to interact with Metabase         | The password for a Metabase admin account.                                                                                                                                                                                                          |
      +| Variable            | Required                                        | Description                                                                                                                                                                                                                                         |
      +|---------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
      +| `BOT.TOKEN`         | Always                                          | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)).                                                                                                                                                               |
      +| `GUILD.ID`          | When using the bootstrapping script             | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)).                                                                                                                                                                  |
      +| `API_KEYS.SITE_API` | When running bot without Docker                 | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below.                                                                |
      +| `SENTRY_DSN`        | When connecting the bot to sentry               | The DSN of the sentry monitor.                                                                                                                                                                                                                      |
      +| `TRACE_LOGGERS `    | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. |
      +| `DEBUG`             | In production                                   | `true` or `false`, depending on whether to enable debug mode, affecting the behavior of certain features. `true` by default.                                                                                                                        |
      +| `REDIS.PASSWORD`    | When not using FakeRedis                        | The password to connect to the Redis database (see [Optional: Working with Redis](#optional-working-with-redis)).                                                                                                                                   |
      +| `USE_METRICITY`     | When using Metricity                            | `true` or `false`, depending on whether to enable metrics collection using Metricity (see [Optional: Working with Metricity](#optional-working-with-metricity)). `false` by default.                                                                |
      +| `API_KEYS.GITHUB`   | When you wish to interact with GitHub           | The API key to interact with GitHub, for example to download files for the branding manager.                                                                                                                                                        |
      +| `METABASE.USERNAME` | When you wish to interact with Metabase         | The username for a Metabase admin account.                                                                                                                                                                                                          |
      +| `METABASE.PASSWORD` | When you wish to interact with Metabase         | The password for a Metabase admin account.                                                                                                                                                                                                          |
       
       ---
       
      -- 
      cgit v1.2.3
      
      
      From 70f354c43ad77ed5c5bbce7b81e4dd743e90362d Mon Sep 17 00:00:00 2001
      From: Amrou 
      Date: Sun, 26 Feb 2023 01:22:39 +0100
      Subject: prefix sentry_dsn & trace_loggers with `bot.`
      
      ---
       .../guides/pydis-guides/contributing/bot.md        | 26 +++++++++++-----------
       1 file changed, 13 insertions(+), 13 deletions(-)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index 83f541e1..f20a2829 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -784,19 +784,19 @@ If you find any bugs in the bot or would like to request a feature, feel free to
       ### Appendix: Full ENV File Options
       The following is a list of all available environment variables used by the bot:
       
      -| Variable            | Required                                        | Description                                                                                                                                                                                                                                         |
      -|---------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
      -| `BOT.TOKEN`         | Always                                          | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)).                                                                                                                                                               |
      -| `GUILD.ID`          | When using the bootstrapping script             | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)).                                                                                                                                                                  |
      -| `API_KEYS.SITE_API` | When running bot without Docker                 | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below.                                                                |
      -| `SENTRY_DSN`        | When connecting the bot to sentry               | The DSN of the sentry monitor.                                                                                                                                                                                                                      |
      -| `TRACE_LOGGERS `    | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. |
      -| `DEBUG`             | In production                                   | `true` or `false`, depending on whether to enable debug mode, affecting the behavior of certain features. `true` by default.                                                                                                                        |
      -| `REDIS.PASSWORD`    | When not using FakeRedis                        | The password to connect to the Redis database (see [Optional: Working with Redis](#optional-working-with-redis)).                                                                                                                                   |
      -| `USE_METRICITY`     | When using Metricity                            | `true` or `false`, depending on whether to enable metrics collection using Metricity (see [Optional: Working with Metricity](#optional-working-with-metricity)). `false` by default.                                                                |
      -| `API_KEYS.GITHUB`   | When you wish to interact with GitHub           | The API key to interact with GitHub, for example to download files for the branding manager.                                                                                                                                                        |
      -| `METABASE.USERNAME` | When you wish to interact with Metabase         | The username for a Metabase admin account.                                                                                                                                                                                                          |
      -| `METABASE.PASSWORD` | When you wish to interact with Metabase         | The password for a Metabase admin account.                                                                                                                                                                                                          |
      +| Variable             | Required                                        | Description                                                                                                                                                                                                                                         |
      +|----------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
      +| `BOT.TOKEN`          | Always                                          | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)).                                                                                                                                                               |
      +| `GUILD.ID`           | When using the bootstrapping script             | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)).                                                                                                                                                                  |
      +| `API_KEYS.SITE_API`  | When running bot without Docker                 | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below.                                                                |
      +| `BOT.SENTRY_DSN`     | When connecting the bot to sentry               | The DSN of the sentry monitor.                                                                                                                                                                                                                      |
      +| `BOT.TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. |
      +| `DEBUG`              | In production                                   | `true` or `false`, depending on whether to enable debug mode, affecting the behavior of certain features. `true` by default.                                                                                                                        |
      +| `REDIS.PASSWORD`     | When not using FakeRedis                        | The password to connect to the Redis database (see [Optional: Working with Redis](#optional-working-with-redis)).                                                                                                                                   |
      +| `USE_METRICITY`      | When using Metricity                            | `true` or `false`, depending on whether to enable metrics collection using Metricity (see [Optional: Working with Metricity](#optional-working-with-metricity)). `false` by default.                                                                |
      +| `API_KEYS.GITHUB`    | When you wish to interact with GitHub           | The API key to interact with GitHub, for example to download files for the branding manager.                                                                                                                                                        |
      +| `METABASE.USERNAME`  | When you wish to interact with Metabase         | The username for a Metabase admin account.                                                                                                                                                                                                          |
      +| `METABASE.PASSWORD`  | When you wish to interact with Metabase         | The password for a Metabase admin account.                                                                                                                                                                                                          |
       
       ---
       
      -- 
      cgit v1.2.3
      
      
      From 7378c288ad261714eb8111875134f36bf42b299e Mon Sep 17 00:00:00 2001
      From: Ibrahim 
      Date: Tue, 28 Feb 2023 01:51:59 +0530
      Subject: remove trashcan emoji from required variables
      
      ---
       .../content/resources/guides/pydis-guides/contributing/sir-lancebot.md   | 1 -
       1 file changed, 1 deletion(-)
      
      (limited to 'pydis_site/apps')
      
      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 8b97cf06..142e6534 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
      @@ -81,7 +81,6 @@ The following variables are needed for running Sir Lancebot:
       | `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 |
      -| `TRASHCAN_EMOJI` | The full emoji to use for the trashcan. Format should be like the output of sending `\:emoji:` on discord. |
       
       [**Full environment variable reference for this project.**](./env-var-reference)
       
      -- 
      cgit v1.2.3
      
      
      From bfa955417ae582a797c31dd462bb0279fb67c64e Mon Sep 17 00:00:00 2001
      From: shtlrs 
      Date: Tue, 28 Feb 2023 09:36:53 +0100
      Subject: Update the note about ignoring .env
      
      ---
       .../apps/content/resources/guides/pydis-guides/contributing/bot.md    | 4 ++--
       1 file changed, 2 insertions(+), 2 deletions(-)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index f20a2829..03c2444b 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -56,7 +56,7 @@ GUILD.ID=YourDiscordTestServerIdHere
       ```
       See [here](../creating-bot-account) for help with obtaining the bot token and [here](../obtaining-discord-ids#guild-id) for help with obtaining the guild's id
       
      -**Note**: The `.env` file will be ignored by commits.
      +**Note**: The `.env` is and should remain ignored by git, otherwise you risk pushing sensitive information.
       ##### 1.2 Setting up the script environment
       The bootstrapping script is Python code like any other. To run it locally, you will need the right version of Python with the necessary packages installed:
       
      @@ -606,7 +606,7 @@ We understand this is tedious which is why we **recommend** using the [automatic
       The second file you need to create is the one containing the environment variables, and needs to be named `.env`.
       Inside, add the line `BOT.TOKEN=YourDiscordBotTokenHere`. See [here](../creating-bot-account) for help with obtaining the bot token.
       
      -**Note**: The `.env` file will be ignored by commits.
      +**Note**: The `.env` is and should remain ignored by git, otherwise you risk pushing sensitive information.
       
       ---
       
      -- 
      cgit v1.2.3
      
      
      From 30033da4c74df1ae47e30f4a63eef7085b06712f Mon Sep 17 00:00:00 2001
      From: shtlrs 
      Date: Tue, 28 Feb 2023 09:40:56 +0100
      Subject: Use "Python program" instead of "Python code" to describe the script
      
      ---
       .../apps/content/resources/guides/pydis-guides/contributing/bot.md     | 3 ++-
       1 file changed, 2 insertions(+), 1 deletion(-)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index 03c2444b..c3189bbe 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -58,7 +58,8 @@ See [here](../creating-bot-account) for help with obtaining the bot token and [h
       
       **Note**: The `.env` is and should remain ignored by git, otherwise you risk pushing sensitive information.
       ##### 1.2 Setting up the script environment
      -The bootstrapping script is Python code like any other. To run it locally, you will need the right version of Python with the necessary packages installed:
      +The bootstrapping script is a Python program so you will need a compatible Python version and the necessary dependencies installed,
      +which are all detailed here:
       
       1. Make sure you follow steps `1` and `2` [here](#setting-up-a-development-environment)
       2. [Install the `config-bootstrap` dependency group](../installing-project-dependencies#installing-specific-dependency-groups).
      -- 
      cgit v1.2.3
      
      
      From fefe1e7b80ee6d8f162695d47260d818a414e034 Mon Sep 17 00:00:00 2001
      From: shtlrs 
      Date: Tue, 28 Feb 2023 09:45:03 +0100
      Subject: Mention that only necessary component IDs needs to be added when
       configuring manually
      
      ---
       .../apps/content/resources/guides/pydis-guides/contributing/bot.md   | 5 +++--
       1 file changed, 3 insertions(+), 2 deletions(-)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index c3189bbe..4322d0a6 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -93,8 +93,9 @@ It also contains configurations such as how long it takes for a help channel to
       If you decided to use the bootstrapping script, you'll find that this file has already been created (which we recommend),
       otherwise you'll need to create it manually.
       
      -To run the bot in your test server, you will **only** need to add the **necessary** configuration values for the channels/roles/categories, etc.
      -that you'll be using for testing
      +If you decide to set the configuration values manually, you will **only** need to set the values for the channels, roles, categories, etc. 
      +that are used by the component you are developing.
      +
       
       Let's take an example where we suppose we'll only be testing a feature that needs the `announcements` channel.
       
      -- 
      cgit v1.2.3
      
      
      From 1c23a1715496332a25f2548e208e4e09c31d8be9 Mon Sep 17 00:00:00 2001
      From: shtlrs 
      Date: Tue, 28 Feb 2023 09:50:15 +0100
      Subject: use concrete ids for the channel examples
      
      ---
       .../resources/guides/pydis-guides/contributing/bot.md        | 12 ++++++------
       1 file changed, 6 insertions(+), 6 deletions(-)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index 4322d0a6..c991d432 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -111,8 +111,8 @@ class _Channels(EnvConfig):
       
           EnvConfig.Config.env_prefix = "channels."
       
      -    announcements = 123
      -    changelog = 456
      +    announcements = 1079790565794779156
      +    changelog = 1077877318564991006
       
       # Instantiate the class & load the configuration
       Channels = _Channels()
      @@ -121,7 +121,7 @@ Channels = _Channels()
       `.env.server` file
       
       ```text
      -channels.announcements=789
      +channels.announcements=1077875228002234398
       ```
       
       When you launch your bot, `pydantic` will load up the server constants from the `.env.server` file if they exist.
      @@ -131,13 +131,13 @@ Each constants class will define its own prefix, which will make `pydantic` look
       In our example, this will imply that pydantic will look for both `channels.announcements` and `channels.changelog` in the `.env.server` file.
       
       As you can see here, only `channels.announcements` has been defined in the `.env.server` file since it's the only one needed, which will tell `pydantic`
      -to use the value **789** for the `announcements` attribute instead of the default **456**, and use the default value for the `changelog` attribute
      +to use the value **1077875228002234398** for the `announcements` attribute instead of the default **1079790565794779156**, and use the default value for the `changelog` attribute
       
       ```python
       >>> Channels.announcements
      -789
      +1077875228002234398
       >>> Channels.changelong
      -456
      +1077877318564991006
       ```
       
       See [here](../obtaining-discord-ids) for help with obtaining Discord IDs.
      -- 
      cgit v1.2.3
      
      
      From a3a54a1c69463429520d8cf8530f8c3e95b5c86f Mon Sep 17 00:00:00 2001
      From: shtlrs 
      Date: Tue, 28 Feb 2023 09:53:01 +0100
      Subject: Appease linter
      
      ---
       .../apps/content/resources/guides/pydis-guides/contributing/bot.md      | 2 +-
       1 file changed, 1 insertion(+), 1 deletion(-)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index c991d432..65f0d818 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -93,7 +93,7 @@ It also contains configurations such as how long it takes for a help channel to
       If you decided to use the bootstrapping script, you'll find that this file has already been created (which we recommend),
       otherwise you'll need to create it manually.
       
      -If you decide to set the configuration values manually, you will **only** need to set the values for the channels, roles, categories, etc. 
      +If you decide to set the configuration values manually, you will **only** need to set the values for the channels, roles, categories, etc.
       that are used by the component you are developing.
       
       
      -- 
      cgit v1.2.3
      
      
      From 3001c2d43cbf10975bef9de1687c56882428f96f Mon Sep 17 00:00:00 2001
      From: shtlrs 
      Date: Tue, 28 Feb 2023 15:03:31 +0100
      Subject: bump python version to 3.10
      
      ---
       .../content/resources/guides/pydis-guides/contributing/sir-lancebot.md  | 2 +-
       1 file changed, 1 insertion(+), 1 deletion(-)
      
      (limited to 'pydis_site/apps')
      
      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..b4756be2 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)
      -- 
      cgit v1.2.3
      
      
      From 6e687e4228d7f757c6a09d462c866974cfdbacb0 Mon Sep 17 00:00:00 2001
      From: shtlrs 
      Date: Tue, 28 Feb 2023 15:08:06 +0100
      Subject: add wildcard for patch version
      
      ---
       .../content/resources/guides/pydis-guides/contributing/sir-lancebot.md  | 2 +-
       1 file changed, 1 insertion(+), 1 deletion(-)
      
      (limited to 'pydis_site/apps')
      
      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 b4756be2..fdc0e257 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.10](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)
      -- 
      cgit v1.2.3
      
      
      From adc8540bd1ded4134552b42dadb13a8ff228f660 Mon Sep 17 00:00:00 2001
      From: shtlrs 
      Date: Thu, 2 Mar 2023 14:48:24 +0100
      Subject: update all instances of bootstrap_config with botstrap
      
      ---
       .../apps/content/resources/guides/pydis-guides/contributing/bot.md    | 4 ++--
       1 file changed, 2 insertions(+), 2 deletions(-)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index 65f0d818..85198f8b 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -39,7 +39,7 @@ You now have both the bot's code and a server to run it on. It's time you to con
       This can be done either automatically or manually, and we'll be detailing the steps for both.
       
       #### Automatic configuration
      -To make setup much easier, there is a file called `bootstrap_config.py` that represents a script to bootstrap the configuration for you and help you get started immediately
      +To make setup much easier, there is a file called `botstrap.py` that represents a script to bootstrap the configuration for you and help you get started immediately
       without having to spend much time copying ids from your newly created server into your configuration file.
       
       **Note**: This phase can be skipped and done manually, but would require extra manual work.
      @@ -76,7 +76,7 @@ $ poetry run task configure
       or, without poetry and from the root directory
       
       ```shell
      -python3 -m bootstrap_config
      +python3 -m botstrap
       ```
       
       Once the script has finished running, you'll notice the creation of a new file called [`.env.server`](#envserver) at your project's root directory.
      -- 
      cgit v1.2.3
      
      
      From 2fc356c15e281240bff766a2a83ae92c6742c199 Mon Sep 17 00:00:00 2001
      From: shtlrs 
      Date: Thu, 2 Mar 2023 15:01:53 +0100
      Subject: add a section that details help forum setup
      
      ---
       .../resources/guides/pydis-guides/contributing/bot.md  | 18 ++++++++++++++++++
       1 file changed, 18 insertions(+)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index 85198f8b..7aa18ccc 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -612,6 +612,24 @@ Inside, add the line `BOT.TOKEN=YourDiscordBotTokenHere`. See [here](../creating
       
       ---
       
      +### Working with the help forum
      +If you will be working on a feature that includes the python help forum, you will need to use `Forum Channels`.
      +
      +Forum channels cannot be included in a template, which is why this needs to be done by hand for the time being.
      +
      +To activate forum channels, your Discord server needs to have the community feature.
      +If that's not the case already, here are the steps required to do it:
      +1. Go to server settings
      +2. Scroll down to the `COMMUNITY` section and click on `Enable Community`
      +3. Click on `Get Started` and fill out the necessary info
      +
      +Once the previous steps are done, all that is left is to:
      +1. Create a new channel
      +2. Choose the `Forum` type
      +3. [Copy its ID](../obtaining-discord-ids#channel-id)
      +4. Add the following line to the `.env.server` file: `channels.python_help={newly_created_forum_channel_id}` 
      +
      +
       ### Run it!
       #### With Docker
       You are now almost ready to run the Python bot. The simplest way to do so is with Docker.
      -- 
      cgit v1.2.3
      
      
      From df9181f99ac329044ec40016a6a82ba8ef7dcc4e Mon Sep 17 00:00:00 2001
      From: shtlrs 
      Date: Thu, 2 Mar 2023 15:22:51 +0100
      Subject: appease linter
      
      ---
       .../apps/content/resources/guides/pydis-guides/contributing/bot.md      | 2 +-
       1 file changed, 1 insertion(+), 1 deletion(-)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index 7aa18ccc..272ba10a 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -627,7 +627,7 @@ Once the previous steps are done, all that is left is to:
       1. Create a new channel
       2. Choose the `Forum` type
       3. [Copy its ID](../obtaining-discord-ids#channel-id)
      -4. Add the following line to the `.env.server` file: `channels.python_help={newly_created_forum_channel_id}` 
      +4. Add the following line to the `.env.server` file: `channels.python_help={newly_created_forum_channel_id}`
       
       
       ### Run it!
      -- 
      cgit v1.2.3
      
      
      From 548131617b93482c9591e4d5b9ebe78aed36d88b Mon Sep 17 00:00:00 2001
      From: mbaruh 
      Date: Sat, 4 Mar 2023 20:04:41 +0200
      Subject: Migrate infraction type `mute` to `timeout`
      
      ---
       .../api/migrations/0086_alter_mute_to_timeout.py   | 25 ++++++++++++++++++++++
       pydis_site/apps/api/models/bot/infraction.py       |  2 +-
       pydis_site/apps/api/tests/test_infractions.py      | 24 ++++++++++-----------
       3 files changed, 38 insertions(+), 13 deletions(-)
       create mode 100644 pydis_site/apps/api/migrations/0086_alter_mute_to_timeout.py
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/api/migrations/0086_alter_mute_to_timeout.py b/pydis_site/apps/api/migrations/0086_alter_mute_to_timeout.py
      new file mode 100644
      index 00000000..8eb3ff6d
      --- /dev/null
      +++ b/pydis_site/apps/api/migrations/0086_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', '0085_add_thread_id_to_nominations'),
      +    ]
      +
      +    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/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py
      index 218ee5ec..fcf8651e 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"),
      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):
      -- 
      cgit v1.2.3
      
      
      From 47308422a2a34d9fc66f0c5f7b423b12a775d919 Mon Sep 17 00:00:00 2001
      From: shtlrs 
      Date: Wed, 8 Mar 2023 12:38:37 +0100
      Subject: update prefix to underscore: _
      
      ---
       .../guides/pydis-guides/contributing/bot.md        | 616 ++++++++++-----------
       1 file changed, 308 insertions(+), 308 deletions(-)
      
      (limited to 'pydis_site/apps')
      
      diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      index 272ba10a..07881543 100644
      --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md
      @@ -46,13 +46,13 @@ without having to spend much time copying ids from your newly created server int
       
       ##### 1. Script setup
       ##### 1.1. Environment variables
      -You will need to create a file called `.env` which will contain two required values for the script to work: `BOT.TOKEN` and `GUILD.ID`
      +You will need to create a file called `.env` which will contain two required values for the script to work: `BOT_TOKEN` and `GUILD_ID`
       
       Inside, add the following two lines:
       
       ```text
      -BOT.TOKEN=YourDiscordBotTokenHere
      -GUILD.ID=YourDiscordTestServerIdHere
      +BOT_TOKEN=YourDiscordBotTokenHere
      +GUILD_ID=YourDiscordTestServerIdHere
       ```
       See [here](../creating-bot-account) for help with obtaining the bot token and [here](../obtaining-discord-ids#guild-id) for help with obtaining the guild's id
       
      @@ -109,7 +109,7 @@ class EnvConfig:
       
       class _Channels(EnvConfig):
       
      -    EnvConfig.Config.env_prefix = "channels."
      +    EnvConfig.Config.env_prefix = "channels_"
       
           announcements = 1079790565794779156
           changelog = 1077877318564991006
      @@ -121,16 +121,16 @@ Channels = _Channels()
       `.env.server` file
       
       ```text
      -channels.announcements=1077875228002234398
      +channels_announcements=1077875228002234398
       ```
       
       When you launch your bot, `pydantic` will load up the server constants from the `.env.server` file if they exist.
       
       Each constants class will define its own prefix, which will make `pydantic` look for variables that will look like `{{env_prefix}}{{attribute_name}}` in the environment files
       
      -In our example, this will imply that pydantic will look for both `channels.announcements` and `channels.changelog` in the `.env.server` file.
      +In our example, this will imply that pydantic will look for both `channels_announcements` and `channels_changelog` in the `.env.server` file.
       
      -As you can see here, only `channels.announcements` has been defined in the `.env.server` file since it's the only one needed, which will tell `pydantic`
      +As you can see here, only `channels_announcements` has been defined in the `.env.server` file since it's the only one needed, which will tell `pydantic`
       to use the value **1077875228002234398** for the `announcements` attribute instead of the default **1079790565794779156**, and use the default value for the `changelog` attribute
       
       ```python
      @@ -160,290 +160,290 @@ See [here](../obtaining-discord-ids) for help with obtaining Discord IDs.
       
       
       # Channels configuration
      -channels.announcements=�
      -channels.changelog=�
      -channels.mailing_lists=�
      -channels.python_events=�
      -channels.python_news=�
      -channels.reddit=�
      -
      -channels.dev_contrib=�
      -channels.dev_core=�
      -channels.dev_log=�
      -
      -channels.meta=�
      -channels.python_general=�
      -
      -channels.help_system_forum=�
      -
      -channels.attachment_log=�
      -channels.filter_log=�
      -channels.message_log=�
      -channels.mod_log=�
      -channels.nomination_archive=�
      -channels.user_log=�
      -channels.voice_log=�
      -
      -channels.off_topic_0=�
      -channels.off_topic_1=�
      -channels.off_topic_2=�
      -
      -channels.bot_commands=�
      -channels.discord_bots=�
      -channels.esoteric=�
      -channels.voice_gate=�
      -channels.code_jam_planning=�
      +channels_announcements=�
      +channels_changelog=�
      +channels_mailing_lists=�
      +channels_python_events=�
      +channels_python_news=�
      +channels_reddit=�
      +
      +channels_dev_contrib=�
      +channels_dev_core=�
      +channels_dev_log=�
      +
      +channels_meta=�
      +channels_python_general=�
      +
      +channels_help_system_forum=�
      +
      +channels_attachment_log=�
      +channels_filter_log=�
      +channels_message_log=�
      +channels_mod_log=�
      +channels_nomination_archive=�
      +channels_user_log=�
      +channels_voice_log=�
      +
      +channels_off_topic_0=�
      +channels_off_topic_1=�
      +channels_off_topic_2=�
      +
      +channels_bot_commands=�
      +channels_discord_bots=�
      +channels_esoteric=�
      +channels_voice_gate=�
      +channels_code_jam_planning=�
       
       ### Staff
      -channels.admins=�
      -channels.admin_spam=�
      -channels.defcon=�
      -channels.helpers=�
      -channels.incidents=�
      -channels.incidents_archive=�
      -channels.mod_alerts=�
      -channels.mod_meta=�
      -channels.mods=�
      -channels.nominations=�
      -channels.nomination_voting=�
      -channels.organisation=�
      +channels_admins=�
      +channels_admin_spam=�
      +channels_defcon=�
      +channels_helpers=�
      +channels_incidents=�
      +channels_incidents_archive=�
      +channels_mod_alerts=�
      +channels_mod_meta=�
      +channels_mods=�
      +channels_nominations=�
      +channels_nomination_voting=�
      +channels_organisation=�
       
       ### Staff announcement channels
      -channels.admin_announcements=�
      -channels.mod_announcements=�
      -channels.staff_announcements=�
      -channels.staff_info=�
      -channels.staff_lounge=�
      +channels_admin_announcements=�
      +channels_mod_announcements=�
      +channels_staff_announcements=�
      +channels_staff_info=�
      +channels_staff_lounge=�
       
       ### Voice Channels
      -channels.admins_voice=�
      -channels.code_help_voice_0=�
      -channels.code_help_voice_1=�
      -channels.general_voice_0=�
      -channels.general_voice_1=�
      -channels.staff_voice=�
      +channels_admins_voice=�
      +channels_code_help_voice_0=�
      +channels_code_help_voice_1=�
      +channels_general_voice_0=�
      +channels_general_voice_1=�
      +channels_staff_voice=�
       
      -channels.black_formatter=�
      +channels_black_formatter=�
       
       ### Voice Chat
      -channels.code_help_chat_0=�
      -channels.code_help_chat_1=�
      -channels.staff_voice_chat=�
      -channels.voice_chat_0=�
      -channels.voice_chat_1=�
      +channels_code_help_chat_0=�
      +channels_code_help_chat_1=�
      +channels_staff_voice_chat=�
      +channels_voice_chat_0=�
      +channels_voice_chat_1=�
       
      -channels.big_brother_logs=�
      -channels.duck_pond=�
      -channels.roles=�
      +channels_big_brother_logs=�
      +channels_duck_pond=�
      +channels_roles=�
       
       ##### << Replace the following � characters with the role IDs in your test server >> #####
       
       
       # Roles configuration
       
      -roles.advent_of_code=�
      -roles.announcements=�
      -roles.lovefest=�
      -roles.pyweek_announcements=�
      -roles.revival_of_code=�
      -roles.legacy_help_channels_access=�
      -
      -roles.contributors=�
      -roles.help_cooldown=�
      -roles.muted=�
      -roles.partners=�
      -roles.python_community=�
      -roles.sprinters=�
      -roles.voice_verified=�
      +roles_advent_of_code=�
      +roles_announcements=�
      +roles_lovefest=�
      +roles_pyweek_announcements=�
      +roles_revival_of_code=�
      +roles_legacy_help_channels_access=�
      +
      +roles_contributors=�
      +roles_help_cooldown=�
      +roles_muted=�
      +roles_partners=�
      +roles_python_community=�
      +roles_sprinters=�
      +roles_voice_verified=�
       
       ### Streaming
      -roles.video=�
      +roles_video=�
       
       ### Staff
      -roles.admins=�
      -roles.core_developers=�
      -roles.code_jam_event_team=�
      -roles.devops=�
      -roles.domain_leads=�
      -roles.events_lead=�
      -roles.helpers=�
      -roles.moderators=�
      -roles.mod_team=�
      -roles.owners=�
      -roles.project_leads=�
      +roles_admins=�
      +roles_core_developers=�
      +roles_code_jam_event_team=�
      +roles_devops=�
      +roles_domain_leads=�
      +roles_events_lead=�
      +roles_helpers=�
      +roles_moderators=�
      +roles_mod_team=�
      +roles_owners=�
      +roles_project_leads=�
       
       ### Code Jam
      -roles.jammers=�
      +roles_jammers=�
       
       ### Patreon
      -roles.patreon_tier_1=�
      -roles.patreon_tier_2=�
      -roles.patreon_tier_3=�
      +roles_patreon_tier_1=�
      +roles_patreon_tier_2=�
      +roles_patreon_tier_3=�
       
       ##### << Replace the following � characters with the category IDs in your test server >> #####
       
       # Categories configuration
       
      -categories.logs=�
      -categories.moderators=�
      -categories.modmail=�
      -categories.appeals=�
      -categories.appeals2=�
      -categories.voice=�
      +categories_logs=�
      +categories_moderators=�
      +categories_modmail=�
      +categories_appeals=�
      +categories_appeals2=�
      +categories_voice=�
       
       ### 2021 Summer Code Jam
      -categories.summer_code_jam=�
      +categories_summer_code_jam=�
       
       ##### << Replace the following � character with the ID of your test server >> #####
       
       # Guild configuration
      -guild.id=�
      -guild.invite="https://discord.gg/python"
      +guild_id=�
      +guild_invite="https://discord.gg/python"
       
       
       ##### << Replace the following � characters with the webhook IDs in your test server >> #####
       
       # Webhooks configuration
       
      -webhooks.big_brother.id=�
      -webhooks.dev_log.id=�
      -webhooks.duck_pond.id=�
      -webhooks.incidents.id=�
      -webhooks.incidents_archive.id=�
      -webhooks.python_news.id=�
      +webhooks_big_brother_id=�
      +webhooks_dev_log_id=�
      +webhooks_duck_pond_id=�
      +webhooks_incidents_id=�
      +webhooks_incidents_archive_id=�
      +webhooks_python_news_id=�
       
       # Big brother configuration
      -big_brother.header_message_limit=15
      -big_brother.log_delay=15
      +big_brother_header_message_limit=15
      +big_brother_log_delay=15
       
       # Code Block configuration
       
      -code_block.cooldown_seconds=300
      -code_block.minimum_lines=4
      +code_block_cooldown_seconds=300
      +code_block_minimum_lines=4
       
       
       # Colours configuration
       
      -colours.blue=0x3775a8
      -colours.bright_green=0x01d277
      -colours.orange=0xe67e22
      -colours.pink=0xcf84e0
      -colours.purple=0xb734eb
      -colours.soft_green=0x68c290
      -colours.soft_orange=0xf9cb54
      -colours.soft_red=0xcd6d6d
      -colours.white=0xfffffe
      -colours.yellow=0xffd241
      +colours_blue=0x3775a8
      +colours_bright_green=0x01d277
      +colours_orange=0xe67e22
      +colours_pink=0xcf84e0
      +colours_purple=0xb734eb
      +colours_soft_green=0x68c290
      +colours_soft_orange=0xf9cb54
      +colours_soft_red=0xcd6d6d
      +colours_white=0xfffffe
      +colours_yellow=0xffd241
       
       # Free configuration
      -free.activity_timeout=600
      -free.cooldown_per=60.0
      -free.cooldown_rate=1
      +free_activity_timeout=600
      +free_cooldown_per=60.0
      +free_cooldown_rate=1
       
       # Antispam configuration
      -antispam.rules.attachments.interval=10
      -antispam.rules.attachments.max=10
      +antispam_rules_attachments_interval=10
      +antispam_rules_attachments_max=10
       
      -antispam.rules.burst.interval=10
      -antispam.rules.burst.max=7
      +antispam_rules_burst_interval=10
      +antispam_rules_burst_max=7
       
      -antispam.rules.chars.interval=5
      -antispam.rules.chars.max=200
      +antispam_rules_chars_interval=5
      +antispam_rules_chars_max=200
       
      -antispam.rules.discord_emojis.interval=10
      -antispam.rules.discord_emojis.max=20
      +antispam_rules_discord_emojis_interval=10
      +antispam_rules_discord_emojis_max=20
       
      -antispam.rules.duplicates.interval=10
      -antispam.rules.duplicates.max=3
      +antispam_rules_duplicates_interval=10
      +antispam_rules_duplicates_max=3
       
      -antispam.rules.links.interval=10
      -antispam.rules.links.max=10
      +antispam_rules_links_interval=10
      +antispam_rules_links_max=10
       
      -antispam.rules.mentions.interval=10
      -antispam.rules.mentions.max=5
      +antispam_rules_mentions_interval=10
      +antispam_rules_mentions_max=5
       
      -antispam.rules.newlines.interval=10
      -antispam.rules.newlines.max=100
      -antispam.rules.newlines.max_consecutive=10
      +antispam_rules_newlines_interval=10
      +antispam_rules_newlines_max=100
      +antispam_rules_newlines_max_consecutive=10
       
      -antispam.rules.role_mentions.interval=10
      -antispam.rules.role_mentions.max=3
      +antispam_rules_role_mentions_interval=10
      +antispam_rules_role_mentions_max=3
       
       
      -antispam.cache_size=100
      -antispam.clean_offending=true
      -antispam.ping_everyone=true
      -antispam.punishment.remove_after=600
      +antispam_cache_size=100
      +antispam_clean_offending=true
      +antispam_ping_everyone=true
      +antispam_punishment_remove_after=600
       
       
       # Help channels configuration
      -help_channels.enable=true
      -help_channels.idle_minutes=30
      -help_channels.deleted_idle_minutes=5
      +help_channels_enable=true
      +help_channels_idle_minutes=30
      +help_channels_deleted_idle_minutes=5
       
       # Redirect output configuration
      -redirect_output.delete_delay=15
      -redirect_output.delete_invocation=true
      +redirect_output_delete_delay=15
      +redirect_output_delete_invocation=true
       
       # Duck pond configuration
      -duckpond.threshold=7
      +duckpond_threshold=7
       
       # Python news configuration
      -python_news.mail_lists=
      +python_news_mail_lists=
       
       # Voice gate configuration
      -voice_gate.bot_message_delete_delay=10
      -voice_gate.minimum_activity_blocks=3
      -voice_gate.minimum_days_member=3
      -voice_gate.minimum_messages=50
      -voice_gate.voice_ping_delete_delay=60
      +voice_gate_bot_message_delete_delay=10
      +voice_gate_minimum_activity_blocks=3
      +voice_gate_minimum_days_member=3
      +voice_gate_minimum_messages=50
      +voice_gate_voice_ping_delete_delay=60
       
       # Branding configuration
      -branding.cycle_frequency=3
      +branding_cycle_frequency=3
       
       # Video permisions configuration
      -video_permission.default_permission_duration=5
      +video_permission_default_permission_duration=5
       
       # Redis configuration
      -redis.host="redis.default.svc.cluster.local"
      -redis.port=6379
      -redis.use_fakeredis=false  # If this is true, Bot will use fakeredis.aioredis
      +redis_host="redis.default.svc.cluster.local"
      +redis_port=6379
      +redis_use_fakeredis=false  # If this is true, Bot will use fakeredis.aioredis
       
       # Cleaning configuration
      -clean.message_limit=10000
      +clean_message_limit=10000
       
       # Stats configuration
      -stats.presence_update_timeout=30
      -stats.statsd_host="graphite.default.svc.cluster.local"
      +stats_presence_update_timeout=30
      +stats_statsd_host="graphite.default.svc.cluster.local"
       
       # Cooldowns configuration
      -cooldowns.tags=60
      +cooldowns_tags=60
       
       # Metabase configuration
      -metabase.base_url="http://metabase.default.svc.cluster.local"
      -metabase.public_url="https://metabase.pythondiscord.com"
      -metabase.max_session_age=20_160
      +metabase_base_url="http://metabase.default.svc.cluster.local"
      +metabase_public_url="https://metabase.pythondiscord.com"
      +metabase_max_session_age=20_160
       
       # URLs configuration
       
      -urls.snekbox_eval_api="http://snekbox.default.svc.cluster.local/eval"
      -urls.snekbox_311_eval_api="http://snekbox-311.default.svc.cluster.local/eval"
      +urls_snekbox_eval_api="http://snekbox.default.svc.cluster.local/eval"
      +urls_snekbox_311_eval_api="http://snekbox-311.default.svc.cluster.local/eval"
       
       # Discord API
      -urls.discord_api="https://discordapp.com/api/v7/"
      +urls_discord_api="https://discordapp.com/api/v7/"
       
       # Misc endpoints
      -urls.bot_avatar="https://raw.githubusercontent.com/python-discord/branding/main/logos/logo_circle/logo_circle.png"
      -urls.github_bot_repo=https://github.com/python-discord/bot
      +urls_bot_avatar="https://raw.githubusercontent.com/python-discord/branding/main/logos/logo_circle/logo_circle.png"
      +urls_github_bot_repo=https://github.com/python-discord/bot
       
       # Site
      -urls.site="pythondiscord.com"
      -urls.site_schema="https://"
      -urls.site_api="site.default.svc.cluster.local/api"
      -urls.site_api_schema="http://"
      +urls_site="pythondiscord.com"
      +urls_site_schema="https://"
      +urls_site_api="site.default.svc.cluster.local/api"
      +urls_site_api_schema="http://"
       
      -urls.connect_max_retries=3
      -urls.connect_cooldown=5
      +urls_connect_max_retries=3
      +urls_connect_cooldown=5
       
       ##### << The bot shouldn't fail without these, but commands adding specific emojis won't work. >> #####
       ##### << You should at least set the trashcan. Set the incidents emojis if relevant. >> #####
      @@ -451,119 +451,119 @@ urls.connect_cooldown=5
       
       
       # Emojis configuration
      -emojis.badge_bug_hunter="<:bug_hunter_lvl1:743882896372269137>"
      -emojis.badge_bug_hunter_level_2="<:bug_hunter_lvl2:743882896611344505>"
      -emojis.badge_early_supporter="<:early_supporter:743882896909140058>"
      -emojis.badge_hypesquad="<:hypesquad_events:743882896892362873>"
      -emojis.badge_hypesquad_balance="<:hypesquad_balance:743882896460480625>"
      -emojis.badge_hypesquad_bravery="<:hypesquad_bravery:743882896745693335>"
      -emojis.badge_hypesquad_brilliance="<:hypesquad_brilliance:743882896938631248>"
      -emojis.badge_partner="<:partner:748666453242413136>"
      -emojis.badge_staff="<:discord_staff:743882896498098226>"
      -emojis.badge_verified_bot_developer="<:verified_bot_dev:743882897299210310>"
      -emojis.verified_bot="<:verified_bot:811645219220750347>"
      -emojis.bot="<:bot:812712599464443914>"
      -
      -emojis.defcon_shutdown="<:defcondisabled:470326273952972810>"  # noqa: E704
      -emojis.defcon_unshutdown="<:defconenabled:470326274213150730>"  # noqa: E704
      -emojis.defcon_update="<:defconsettingsupdated:470326274082996224>"  # noqa: E704
      -
      -emojis.failmail="<:failmail:633660039931887616>"
      -
      -emojis.incident_actioned="<:incident_actioned:714221559279255583>"
      -emojis.incident_investigating="<:incident_investigating:714224190928191551>"
      -emojis.incident_unactioned="<:incident_unactioned:714223099645526026>"
      -
      -emojis.status_dnd="<:status_dnd:470326272082313216>"
      -emojis.status_idle="<:status_idle:470326266625785866>"
      -emojis.status_offline="<:status_offline:470326266537705472>"
      -emojis.status_online="<:status_online:470326272351010816>"
      -
      -emojis.ducky_dave="<:ducky_dave:742058418692423772>"
      -
      -emojis.trashcan="<:trashcan:637136429717389331>"
      -
      -emojis.bullet="\u2022"
      -emojis.check_mark="\u2705"
      -emojis.cross_mark="\u274C"
      -emojis.new="\U0001F195"
      -emojis.pencil="\u270F"
      -
      -emojis.ok_hand=":ok_hand:"
      +emojis_badge_bug_hunter="<:bug_hunter_lvl1:743882896372269137>"
      +emojis_badge_bug_hunter_level_2="<:bug_hunter_lvl2:743882896611344505>"
      +emojis_badge_early_supporter="<:early_supporter:743882896909140058>"
      +emojis_badge_hypesquad="<:hypesquad_events:743882896892362873>"
      +emojis_badge_hypesquad_balance="<:hypesquad_balance:743882896460480625>"
      +emojis_badge_hypesquad_bravery="<:hypesquad_bravery:743882896745693335>"
      +emojis_badge_hypesquad_brilliance="<:hypesquad_brilliance:743882896938631248>"
      +emojis_badge_partner="<:partner:748666453242413136>"
      +emojis_badge_staff="<:discord_staff:743882896498098226>"
      +emojis_badge_verified_bot_developer="<:verified_bot_dev:743882897299210310>"
      +emojis_verified_bot="<:verified_bot:811645219220750347>"
      +emojis_bot="<:bot:812712599464443914>"
      +
      +emojis_defcon_shutdown="<:defcondisabled:470326273952972810>"  # noqa: E704
      +emojis_defcon_unshutdown="<:defconenabled:470326274213150730>"  # noqa: E704
      +emojis_defcon_update="<:defconsettingsupdated:470326274082996224>"  # noqa: E704
      +
      +emojis_failmail="<:failmail:633660039931887616>"
      +
      +emojis_incident_actioned="<:incident_actioned:714221559279255583>"
      +emojis_incident_investigating="<:incident_investigating:714224190928191551>"
      +emojis_incident_unactioned="<:incident_unactioned:714223099645526026>"
      +
      +emojis_status_dnd="<:status_dnd:470326272082313216>"
      +emojis_status_idle="<:status_idle:470326266625785866>"
      +emojis_status_offline="<:status_offline:470326266537705472>"
      +emojis_status_online="<:status_online:470326272351010816>"
      +
      +emojis_ducky_dave="<:ducky_dave:742058418692423772>"
      +
      +emojis_trashcan="<:trashcan:637136429717389331>"
      +
      +emojis_bullet="\u2022"
      +emojis_check_mark="\u2705"
      +emojis_cross_mark="\u274C"
      +emojis_new="\U0001F195"
      +emojis_pencil="\u270F"
      +
      +emojis_ok_hand=":ok_hand:"
       
       # Icons configuration
       
      -icons.crown_blurple="https://cdn.discordapp.com/emojis/469964153289965568.png"
      -icons.crown_green="https://cdn.discordapp.com/emojis/469964154719961088.png"
      -icons.crown_red="https://cdn.discordapp.com/emojis/469964154879344640.png"
      +icons_crown_blurple="https://cdn.discordapp.com/emojis/469964153289965568.png"
      +icons_crown_green="https://cdn.discordapp.com/emojis/469964154719961088.png"
      +icons_crown_red="https://cdn.discordapp.com/emojis/469964154879344640.png"
       
      -icons.defcon_denied="https://cdn.discordapp.com/emojis/472475292078964738.png"
      -icons.defcon_shutdown="https://cdn.discordapp.com/emojis/470326273952972810.png"
      -icons.defcon_unshutdown="https://cdn.discordapp.com/emojis/470326274213150730.png"
      -icons.defcon_update="https://cdn.discordapp.com/emojis/472472638342561793.png"
      +icons_defcon_denied="https://cdn.discordapp.com/emojis/472475292078964738.png"
      +icons_defcon_shutdown="https://cdn.discordapp.com/emojis/470326273952972810.png"
      +icons_defcon_unshutdown="https://cdn.discordapp.com/emojis/470326274213150730.png"
      +icons_defcon_update="https://cdn.discordapp.com/emojis/472472638342561793.png"
       
      -icons.filtering="https://cdn.discordapp.com/emojis/472472638594482195.png"
      +icons_filtering="https://cdn.discordapp.com/emojis/472472638594482195.png"
       
      -icons.green_checkmark="https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-checkmark-dist.png"
      -icons.green_questionmark="https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-question-mark-dist.png"
      +icons_green_checkmark="https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-checkmark-dist.png"
      +icons_green_questionmark="https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-question-mark-dist.png"
       
      -icons.guild_update="https://cdn.discordapp.com/emojis/469954765141442561.png"
      +icons_guild_update="https://cdn.discordapp.com/emojis/469954765141442561.png"
       
      -icons.hash_blurple="https://cdn.discordapp.com/emojis/469950142942806017.png"
      -icons.hash_green="https://cdn.discordapp.com/emojis/469950144918585344.png"
      -icons.hash_red="https://cdn.discordapp.com/emojis/469950145413251072.png"
      +icons_hash_blurple="https://cdn.discordapp.com/emojis/469950142942806017.png"
      +icons_hash_green="https://cdn.discordapp.com/emojis/469950144918585344.png"
      +icons_hash_red="https://cdn.discordapp.com/emojis/469950145413251072.png"
       
      -icons.message_bulk_delete="https://cdn.discordapp.com/emojis/469952898994929668.png"
      -icons.message_delete="https://cdn.discordapp.com/emojis/472472641320648704.png"
      -icons.message_edit="https://cdn.discordapp.com/emojis/472472638976163870.png"
      +icons_message_bulk_delete="https://cdn.discordapp.com/emojis/469952898994929668.png"
      +icons_message_delete="https://cdn.discordapp.com/emojis/472472641320648704.png"
      +icons_message_edit="https://cdn.discordapp.com/emojis/472472638976163870.png"
       
      -icons.pencil="https://cdn.discordapp.com/emojis/470326272401211415.png"
      +icons_pencil="https://cdn.discordapp.com/emojis/470326272401211415.png"
       
      -icons.questionmark="https://cdn.discordapp.com/emojis/512367613339369475.png"
      +icons_questionmark="https://cdn.discordapp.com/emojis/512367613339369475.png"
       
      -icons.remind_blurple="https://cdn.discordapp.com/emojis/477907609215827968.png"
      -icons.remind_green="https://cdn.discordapp.com/emojis/477907607785570310.png"
      -icons.remind_red="https://cdn.discordapp.com/emojis/477907608057937930.png"
      +icons_remind_blurple="https://cdn.discordapp.com/emojis/477907609215827968.png"
      +icons_remind_green="https://cdn.discordapp.com/emojis/477907607785570310.png"
      +icons_remind_red="https://cdn.discordapp.com/emojis/477907608057937930.png"
       
      -icons.sign_in="https://cdn.discordapp.com/emojis/469952898181234698.png"
      -icons.sign_out="https://cdn.discordapp.com/emojis/469952898089091082.png"
      +icons_sign_in="https://cdn.discordapp.com/emojis/469952898181234698.png"
      +icons_sign_out="https://cdn.discordapp.com/emojis/469952898089091082.png"
       
      -icons.superstarify="https://cdn.discordapp.com/emojis/636288153044516874.png"
      -icons.unsuperstarify="https://cdn.discordapp.com/emojis/636288201258172446.png"
      +icons_superstarify="https://cdn.discordapp.com/emojis/636288153044516874.png"
      +icons_unsuperstarify="https://cdn.discordapp.com/emojis/636288201258172446.png"
       
      -icons.token_removed="https://cdn.discordapp.com/emojis/470326273298792469.png"
      +icons_token_removed="https://cdn.discordapp.com/emojis/470326273298792469.png"
       
      -icons.user_ban="https://cdn.discordapp.com/emojis/469952898026045441.png"
      -icons.user_mute="https://cdn.discordapp.com/emojis/472472640100106250.png"
      -icons.user_unban="https://cdn.discordapp.com/emojis/469952898692808704.png"
      -icons.user_unmute="https://cdn.discordapp.com/emojis/472472639206719508.png"
      -icons.user_update="https://cdn.discordapp.com/emojis/469952898684551168.png"
      -icons.user_verified="https://cdn.discordapp.com/emojis/470326274519334936.png"
      -icons.user_warn="https://cdn.discordapp.com/emojis/470326274238447633.png"
      +icons_user_ban="https://cdn.discordapp.com/emojis/469952898026045441.png"
      +icons_user_mute="https://cdn.discordapp.com/emojis/472472640100106250.png"
      +icons_user_unban="https://cdn.discordapp.com/emojis/469952898692808704.png"
      +icons_user_unmute="https://cdn.discordapp.com/emojis/472472639206719508.png"
      +icons_user_update="https://cdn.discordapp.com/emojis/469952898684551168.png"
      +icons_user_verified="https://cdn.discordapp.com/emojis/470326274519334936.png"
      +icons_user_warn="https://cdn.discordapp.com/emojis/470326274238447633.png"
       
      -icons.voice_state_blue="https://cdn.discordapp.com/emojis/656899769662439456.png"
      -icons.voice_state_green="https://cdn.discordapp.com/emojis/656899770094452754.png"
      -icons.voice_state_red="https://cdn.discordapp.com/emojis/656899769905709076.png"
      +icons_voice_state_blue="https://cdn.discordapp.com/emojis/656899769662439456.png"
      +icons_voice_state_green="https://cdn.discordapp.com/emojis/656899770094452754.png"
      +icons_voice_state_red="https://cdn.discordapp.com/emojis/656899769905709076.png"
       
       ##### << Optional - If you don't care about the filtering, help channel and py-news cogs, ignore the rest of this file >> #####
       
       # Filters configuration
      -filters.filter_domains=true
      -filters.filter_everyone_ping=true
      -filters.filter_invites=true
      -filters.filter_zalgo=false
      -filters.watch_regex=true
      -filters.watch_rich_embeds=true
      +filters_filter_domains=true
      +filters_filter_everyone_ping=true
      +filters_filter_invites=true
      +filters_filter_zalgo=false
      +filters_watch_regex=true
      +filters_watch_rich_embeds=true
       
       ### Notifications are not expected for "watchlist" type filters
       
      -filters.notify_user_domains=false
      -filters.notify_user_everyone_ping=true
      -filters.notify_user_invites=true
      -filters.notify_user_zalgo=false
      +filters_notify_user_domains=false
      +filters_notify_user_everyone_ping=true
      +filters_notify_user_invites=true
      +filters_notify_user_zalgo=false
       
      -filters.offensive_msg_delete_days=7
      -filters.ping_everyone=true
      +filters_offensive_msg_delete_days=7
      +filters_ping_everyone=true
       
         
         
      @@ -573,17 +573,17 @@ filters.ping_everyone=true If you don't wish to use the provided `env.server` above, the main values that need overriding are **all** the ones prefixed with: -* `guild.` -* `categories.` -* `channels.` -* `roles.` -* `webhooks.` -* `emojis.` +* `guild_` +* `categories_` +* `channels_` +* `roles_` +* `webhooks_` +* `emojis_` Additionally: -* At this stage, set `redis.use_fakeredis` to `true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). -* Set `urls.site_schema` and `urls.site_api_schema` to `"http://"`. +* At this stage, set `redis_use_fakeredis` to `true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). +* Set `urls_site_schema` and `urls_site_api_schema` to `"http://"`. We understand this is tedious which is why we **recommend** using the [automatic configuration setup](#automatic-configuration) @@ -606,7 +606,7 @@ We understand this is tedious which is why we **recommend** using the [automatic ##### .env The second file you need to create is the one containing the environment variables, and needs to be named `.env`. -Inside, add the line `BOT.TOKEN=YourDiscordBotTokenHere`. See [here](../creating-bot-account) for help with obtaining the bot token. +Inside, add the line `BOT_TOKEN=YourDiscordBotTokenHere`. See [here](../creating-bot-account) for help with obtaining the bot token. **Note**: The `.env` is and should remain ignored by git, otherwise you risk pushing sensitive information. @@ -627,7 +627,7 @@ Once the previous steps are done, all that is left is to: 1. Create a new channel 2. Choose the `Forum` type 3. [Copy its ID](../obtaining-discord-ids#channel-id) -4. Add the following line to the `.env.server` file: `channels.python_help={newly_created_forum_channel_id}` +4. Add the following line to the `.env.server` file: `channels_python_help={newly_created_forum_channel_id}` ### Run it! @@ -656,10 +656,10 @@ You are now almost ready to run the Python bot. The simplest way to do so is wit In your `.env.server` file: -* Set `urls.site` to `"web:8000"`. +* Set `urls_site` to `"web:8000"`. * If you wish to work with snekbox set the following: - * `urls.snekbox_eval_api` to `"http://snekbox:8060/eval"` - * `urls.snekbox_311_eval_api` to `"http://snekbox-311:8060/eval"`. + * `urls_snekbox_eval_api` to `"http://snekbox:8060/eval"` + * `urls_snekbox_311_eval_api` to `"http://snekbox-311:8060/eval"`. Assuming you have Docker installed **and running**, enter the cloned repo in the command line and type `docker-compose up`. @@ -672,9 +672,9 @@ Your bot is now running, but this method makes debugging with an IDE a fairly in #### With the Bot Running Locally The advantage of this method is that you can run the bot's code in your preferred editor, with debugger and all, while keeping all the setup of the bot's various dependencies inside Docker. -* Append the following line to your `.env` file: `API_KEYS.SITE_API=badbot13m0n8f570f942013fc818f234916ca531`. -* In your `.env.server` file, set `urls.site` to `"localhost:8000"`. If you wish to keep using `web:8000`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. -* To work with snekbox, set `urls.snekbox_eval_api` to `"http://localhost:8060/eval"` and `urls.snekbox_311_eval_api` to `"http://localhost:8065/eval"` +* Append the following line to your `.env` file: `API_KEYS_SITE_API=badbot13m0n8f570f942013fc818f234916ca531`. +* In your `.env.server` file, set `urls_site` to `"localhost:8000"`. If you wish to keep using `web:8000`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. +* To work with snekbox, set `urls_snekbox_eval_api` to `"http://localhost:8060/eval"` and `urls_snekbox_311_eval_api` to `"http://localhost:8065/eval"` You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: @@ -760,15 +760,15 @@ We are always open to more statistics so add as many as you can! --- ### Optional: Working with Redis -In [Configure the Bot](#envserver) you were asked to set `redis.use_fakeredis` to `true`. If you do not need to work on features that rely on Redis, this is enough. Fakeredis will give the illusion that features relying on Redis are saving information properly, but restarting the bot or the specific cog will wipe that information. +In [Configure the Bot](#envserver) you were asked to set `redis_use_fakeredis` to `true`. If you do not need to work on features that rely on Redis, this is enough. Fakeredis will give the illusion that features relying on Redis are saving information properly, but restarting the bot or the specific cog will wipe that information. If you are working on a feature that relies on Redis, you will need to enable Redis to make sure persistency is achieved for the feature across restarts. The first step towards that is going to `.env.server` and setting `redis.use_fakeredis` to `false`. #### Starting Redis in Docker (Recommended) -If you're using the Docker image provided in the project's Docker Compose, open your `.env.server` file. If you're running the bot in Docker, set `redis.host` to `redis`, and if you're running it on the host set it to `localhost`. Set `bot.redis.password` to `""`. +If you're using the Docker image provided in the project's Docker Compose, open your `.env.server` file. If you're running the bot in Docker, set `redis_host` to `redis`, and if you're running it on the host set it to `localhost`. Set `redis_password` to `""`. #### Starting Redis Using Other Methods -You can run your own instance of Redis, but in that case you will need to correctly set `redis.host` and `redis.port`, and the `redis.password` value in `constants.py` should not be overridden. Then, enter the `.env` file, and set `REDIS.PASSWORD` to whatever password you set. +You can run your own instance of Redis, but in that case you will need to correctly set `redis_host` and `redis_port`, and the `redis_password` value in `constants.py` should not be overridden. Then, enter the `.env` file, and set `REDIS_PASSWORD` to whatever password you set. --- @@ -806,17 +806,17 @@ The following is a list of all available environment variables used by the bot: | Variable | Required | Description | |----------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `BOT.TOKEN` | Always | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)). | -| `GUILD.ID` | When using the bootstrapping script | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)). | -| `API_KEYS.SITE_API` | When running bot without Docker | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below. | -| `BOT.SENTRY_DSN` | When connecting the bot to sentry | The DSN of the sentry monitor. | -| `BOT.TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. | +| `BOT_TOKEN` | Always | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)). | +| `GUILD_ID` | When using the bootstrapping script | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)). | +| `API_KEYS_SITE_API` | When running bot without Docker | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below. | +| `BOT_SENTRY_DSN` | When connecting the bot to sentry | The DSN of the sentry monitor. | +| `BOT_TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. | | `DEBUG` | In production | `true` or `false`, depending on whether to enable debug mode, affecting the behavior of certain features. `true` by default. | -| `REDIS.PASSWORD` | When not using FakeRedis | The password to connect to the Redis database (see [Optional: Working with Redis](#optional-working-with-redis)). | +| `REDIS_PASSWORD` | When not using FakeRedis | The password to connect to the Redis database (see [Optional: Working with Redis](#optional-working-with-redis)). | | `USE_METRICITY` | When using Metricity | `true` or `false`, depending on whether to enable metrics collection using Metricity (see [Optional: Working with Metricity](#optional-working-with-metricity)). `false` by default. | -| `API_KEYS.GITHUB` | When you wish to interact with GitHub | The API key to interact with GitHub, for example to download files for the branding manager. | -| `METABASE.USERNAME` | When you wish to interact with Metabase | The username for a Metabase admin account. | -| `METABASE.PASSWORD` | When you wish to interact with Metabase | The password for a Metabase admin account. | +| `API_KEYS_GITHUB` | When you wish to interact with GitHub | The API key to interact with GitHub, for example to download files for the branding manager. | +| `METABASE_USERNAME` | When you wish to interact with Metabase | The username for a Metabase admin account. | +| `METABASE_PASSWORD` | When you wish to interact with Metabase | The password for a Metabase admin account. | --- -- cgit v1.2.3 From b804ef7f47a87ee3a80a7471e3d60107f5bda8fd Mon Sep 17 00:00:00 2001 From: shtlrs Date: Wed, 8 Mar 2023 12:39:42 +0100 Subject: make GUILD_ID always required --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 07881543..38f87c91 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -807,7 +807,7 @@ The following is a list of all available environment variables used by the bot: | Variable | Required | Description | |----------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `BOT_TOKEN` | Always | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)). | -| `GUILD_ID` | When using the bootstrapping script | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)). | +| `GUILD_ID` | Always | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)). | | `API_KEYS_SITE_API` | When running bot without Docker | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below. | | `BOT_SENTRY_DSN` | When connecting the bot to sentry | The DSN of the sentry monitor. | | `BOT_TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. | -- cgit v1.2.3 From 21ed6cdfbde922b4340e78ac1c55e3dbf4f6aa85 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Wed, 8 Mar 2023 12:48:14 +0100 Subject: clarify where redis values should be overriden --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 38f87c91..e8d09a09 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -768,7 +768,8 @@ If you are working on a feature that relies on Redis, you will need to enable Re If you're using the Docker image provided in the project's Docker Compose, open your `.env.server` file. If you're running the bot in Docker, set `redis_host` to `redis`, and if you're running it on the host set it to `localhost`. Set `redis_password` to `""`. #### Starting Redis Using Other Methods -You can run your own instance of Redis, but in that case you will need to correctly set `redis_host` and `redis_port`, and the `redis_password` value in `constants.py` should not be overridden. Then, enter the `.env` file, and set `REDIS_PASSWORD` to whatever password you set. +You can run your own instance of Redis, but in that case you will need to correctly set `redis_host` and `redis_port` in your `.env.server` file and the `REDIS_PASSWORD` in the `.env` file. +**Note**: The previously mentioned variables **SHOULD NOT** be overriden or changed in `constants.py` --- -- cgit v1.2.3 From 781450f8bb76bc7a302a4473f646b2b06a207054 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Thu, 9 Mar 2023 16:33:34 +0100 Subject: update url of site_api --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index e8d09a09..df29e542 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -673,7 +673,7 @@ Your bot is now running, but this method makes debugging with an IDE a fairly in The advantage of this method is that you can run the bot's code in your preferred editor, with debugger and all, while keeping all the setup of the bot's various dependencies inside Docker. * Append the following line to your `.env` file: `API_KEYS_SITE_API=badbot13m0n8f570f942013fc818f234916ca531`. -* In your `.env.server` file, set `urls_site` to `"localhost:8000"`. If you wish to keep using `web:8000`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. +* In your `.env.server` file, set `urls_site_api` to `"localhost:8000/api"`. If you wish to keep using `web:8000`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. * To work with snekbox, set `urls_snekbox_eval_api` to `"http://localhost:8060/eval"` and `urls_snekbox_311_eval_api` to `"http://localhost:8065/eval"` You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: -- cgit v1.2.3 From 57c2cbdcd9b5a31b37a6b469f169b223c283919f Mon Sep 17 00:00:00 2001 From: shtlrs Date: Fri, 10 Mar 2023 10:23:55 +0100 Subject: apply Zig's suggestions --- .../guides/pydis-guides/contributing/bot.md | 81 +++++++++++----------- 1 file changed, 42 insertions(+), 39 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index df29e542..da9e1be5 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -16,7 +16,7 @@ The Python bot is tightly coupled with the Python Discord server, so to have a f It's possible to set the bot to use a single channel for all cogs, but that will cause extreme spam and will be difficult to work with. You can start your own server and set up channels as you see fit, but for your convenience we have a template for a development server you can use: [https://discord.new/zmHtscpYN9E3](https://discord.new/zmHtscpYN9E3). -Keep in mind that this is not an exact mirror of the Python server, but a reduced version for testing purposes. +The server will only contain the channels that are needed by the bot. --- ### Set Up a Bot Account @@ -35,18 +35,20 @@ If your bot fails to start with a `PrivilegedIntentsRequired` exception, this in --- ### Configure the Bot -You now have both the bot's code and a server to run it on. It's time you to connect the two by changing the bot's configurations. +You now have both the bot's code and a server to run it on. It's time for you to connect the two by changing the bot's configurations. This can be done either automatically or manually, and we'll be detailing the steps for both. +**Note**: Skip this step if you would like to configure the bot manually, but that will require more work. + #### Automatic configuration -To make setup much easier, there is a file called `botstrap.py` that represents a script to bootstrap the configuration for you and help you get started immediately -without having to spend much time copying ids from your newly created server into your configuration file. +To make setup much easier, the script in `botstrap.py` bootstraps the configuration for you and helps you get started immediately, +without having to spend much time copying IDs from your newly created server into your configuration file. -**Note**: This phase can be skipped and done manually, but would require extra manual work. +**Note**: The script will also work on existing servers as long as the channel names are the same as the one in Python Discord. ##### 1. Script setup ##### 1.1. Environment variables -You will need to create a file called `.env` which will contain two required values for the script to work: `BOT_TOKEN` and `GUILD_ID` +You will need to create a file called `.env`, which will contain two required values: `BOT_TOKEN` and `GUILD_ID`. Inside, add the following two lines: @@ -54,37 +56,37 @@ Inside, add the following two lines: BOT_TOKEN=YourDiscordBotTokenHere GUILD_ID=YourDiscordTestServerIdHere ``` -See [here](../creating-bot-account) for help with obtaining the bot token and [here](../obtaining-discord-ids#guild-id) for help with obtaining the guild's id +See [here](../creating-bot-account) for help with obtaining the bot token, and [here](../obtaining-discord-ids#guild-id) for help with obtaining the guild's ID. **Note**: The `.env` is and should remain ignored by git, otherwise you risk pushing sensitive information. ##### 1.2 Setting up the script environment The bootstrapping script is a Python program so you will need a compatible Python version and the necessary dependencies installed, which are all detailed here: -1. Make sure you follow steps `1` and `2` [here](#setting-up-a-development-environment) -2. [Install the `config-bootstrap` dependency group](../installing-project-dependencies#installing-specific-dependency-groups). +1. Make sure you have [Python 3.10](https://www.python.org/downloads/) installed. It helps if it is your system's default Python version. +2. [Install Poetry](https://github.com/python-poetry/poetry#installation). +3. [Install the dependencies](../installing-project-dependencies). #### 2. Running the script Once the script setup phase is complete, all that is left is to run it. -To do this, you'll simply need to run the `configure` poetry task like this +To do this, you'll simply need to run the `configure` poetry task: ```shell $ poetry run task configure ``` -or, without poetry and from the root directory +Once the script has finished running, you'll notice the creation of a new file called [`.env.server`](#envserver) at your project's root directory. +This file will contain the extracted IDs from your server which are necessary for your bot to run. -```shell -python3 -m botstrap -``` +**Congratulations**, you have finished the configuration and can now [run your bot](#run-it). -Once the script has finished running, you'll notice the creation of a new file called [`.env.server`](#envserver) at your project's root directory. -This file will contain the extracted ids from your newly created server which are necessary for your bot to run. -**Congratulations**, you have finished the configuration & can now start [running your bot](#run-it) #### Manual configuration + +**Note**: Skip this part if you used the automatic configuration. + ##### .env.server All server configuration values are saved in a file called `.env.server`, which needs to be at the root directory of the cloned code. This file contains the various configurations we use to make the bot run on the Python Discord server, such as channel and role IDs, and the emojis it works with. @@ -96,8 +98,7 @@ otherwise you'll need to create it manually. If you decide to set the configuration values manually, you will **only** need to set the values for the channels, roles, categories, etc. that are used by the component you are developing. - -Let's take an example where we suppose we'll only be testing a feature that needs the `announcements` channel. +For example, if we're testing a feature that only needs the `announcements` channel: `constants.py` @@ -571,7 +572,7 @@ filters_ping_everyone=true
      -If you don't wish to use the provided `env.server` above, the main values that need overriding are **all** the ones prefixed with: +If you wish to set all values in your `env.server` for your testing server, you need to set **all** the ones prefixed with: * `guild_` * `categories_` @@ -585,7 +586,7 @@ Additionally: * At this stage, set `redis_use_fakeredis` to `true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). * Set `urls_site_schema` and `urls_site_api_schema` to `"http://"`. -We understand this is tedious which is why we **recommend** using the [automatic configuration setup](#automatic-configuration) +We understand this is tedious which is why we **heavily recommend** using the [automatic configuration setup](#automatic-configuration)
      -
      -
      - - If you wish to set all values in your `env.server` for your testing server, you need to set **all** the ones prefixed with: * `guild_` -- cgit v1.2.3 From e207147d0d58af2ef6f75586f837ee3c96b8b7a4 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Fri, 10 Mar 2023 22:50:49 +0100 Subject: define .env & .env.server then display their usage --- .../guides/pydis-guides/contributing/bot.md | 37 ++++++++++++++-------- 1 file changed, 24 insertions(+), 13 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 60e5f9b1..3fbec090 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -39,7 +39,23 @@ If your bot fails to start with a `PrivilegedIntentsRequired` exception, this in You now have both the bot's code and a server to run it on. It's time for you to connect the two by changing the bot's configurations. This can be done either automatically or manually, and we'll be detailing the steps for both. -**Note**: Skip this step if you would like to configure the bot manually, but that will require more work. +One thing to know is that the bot relies on precisely **two** configuration files to work + +#### .env.server +All server configuration values are saved in this file, which needs to be at the root directory of the cloned code. +This file contains the various configurations we use to make the bot run on the Python Discord server, such as channel and role IDs, and the emojis it works with. +It also contains configurations such as how long it takes for a help channel to time out, and how many messages a user needs to voice-verify. + +This file will be created for you automatically if you decide to go with [automatic configuration](#automatic-configuration), +otherwise a lot of it has to be done by hand which will be detailed in the [manual configuration](#manual-configuration) section. + +#### .env +This file will mostly contain sensitive information such as your `BOT_TOKEN` and your `REDIS_PASSWORD`, and other configurations related to external services that the bot might use, +such as: `USE_METRICITY`, etc. which are all not necessarily coupled to things that can be fetched from your server, with the only exception to `GUILD_ID`. + +**Notes**: +* Both `.env` and `.env.server` are and should remain ignored by git, otherwise you risk pushing sensitive information. +* Skip the following step if you would like to configure the bot manually, but that will require more work. #### Automatic configuration To make setup much easier, the script in `botstrap.py` bootstraps the configuration for you and helps you get started immediately, @@ -59,7 +75,7 @@ GUILD_ID=YourDiscordTestServerIdHere ``` See [here](../creating-bot-account) for help with obtaining the bot token, and [here](../obtaining-discord-ids#guild-id) for help with obtaining the guild's ID. -**Note**: The `.env` is and should remain ignored by git, otherwise you risk pushing sensitive information. + ##### 1.2 Setting up the script environment The bootstrapping script is a Python program so you will need a compatible Python version and the necessary dependencies installed, which are all detailed here: @@ -83,20 +99,15 @@ This file will contain the extracted IDs from your server which are necessary fo **Congratulations**, you have finished the configuration and can now [run your bot](#run-it). - #### Manual configuration **Note**: Skip this part if you used the automatic configuration. ##### .env.server -All server configuration values are saved in a file called `.env.server`, which needs to be at the root directory of the cloned code. -This file contains the various configurations we use to make the bot run on the Python Discord server, such as channel and role IDs, and the emojis it works with. -It also contains configurations such as how long it takes for a help channel to time out, and how many messages a user needs to voice-verify. +Reading this means that you're ready for a bit of manual labour. +If for some reason you've missed the automatic server setup section, you can read about it [here](#automatic-configuration) -If you decided to use the bootstrapping script, you'll find that this file has already been created (which we recommend), -otherwise you'll need to create it manually. - -If you decide to set the configuration values manually, you will **only** need to set the values for the channels, roles, categories, etc. +To configure the bot manually, you will **only** need to set the values for the channels, roles, categories, etc. that are used by the component you are developing. For example, if we're testing a feature that only needs the `announcements` channel: @@ -120,9 +131,11 @@ class _Channels(EnvConfig): Channels = _Channels() ``` -`.env.server` file +`.env.server` file: ```text +# .env.server + channels_announcements=1077875228002234398 ``` @@ -181,8 +194,6 @@ We understand this is tedious which is why we **heavily recommend** using the [a The second file you need to create is the one containing the environment variables, and needs to be named `.env`. Inside, add the line `BOT_TOKEN=YourDiscordBotTokenHere`. See [here](../creating-bot-account) for help with obtaining the bot token. -**Note**: The `.env` is and should remain ignored by git, otherwise you risk pushing sensitive information. - --- -- cgit v1.2.3 From becd1d6357abb35963898e2f78e55225da2e64fc Mon Sep 17 00:00:00 2001 From: shtlrs Date: Fri, 10 Mar 2023 22:55:11 +0100 Subject: appease linter --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 3fbec090..ccf94046 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -53,7 +53,7 @@ otherwise a lot of it has to be done by hand which will be detailed in the [manu This file will mostly contain sensitive information such as your `BOT_TOKEN` and your `REDIS_PASSWORD`, and other configurations related to external services that the bot might use, such as: `USE_METRICITY`, etc. which are all not necessarily coupled to things that can be fetched from your server, with the only exception to `GUILD_ID`. -**Notes**: +**Notes**: * Both `.env` and `.env.server` are and should remain ignored by git, otherwise you risk pushing sensitive information. * Skip the following step if you would like to configure the bot manually, but that will require more work. -- cgit v1.2.3 From ab5f1d522455a4c2d0d0f080dd1ecc47a9c68ef2 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Sat, 11 Mar 2023 09:33:44 +0100 Subject: update site urls one last time --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index ccf94046..18797ab5 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -50,8 +50,8 @@ This file will be created for you automatically if you decide to go with [automa otherwise a lot of it has to be done by hand which will be detailed in the [manual configuration](#manual-configuration) section. #### .env -This file will mostly contain sensitive information such as your `BOT_TOKEN` and your `REDIS_PASSWORD`, and other configurations related to external services that the bot might use, -such as: `USE_METRICITY`, etc. which are all not necessarily coupled to things that can be fetched from your server, with the only exception to `GUILD_ID`. +This file will mostly contain sensitive information such as your `BOT_TOKEN` and your `REDIS_PASSWORD`. +It will also contain configurations related to external services the bot might use such as `USE_METRICITY`, which are unrelated your server, with the only exception being `GUILD_ID`. **Notes**: * Both `.env` and `.env.server` are and should remain ignored by git, otherwise you risk pushing sensitive information. @@ -240,7 +240,7 @@ Your bot is now running, but this method makes debugging with an IDE a fairly in The advantage of this method is that you can run the bot's code in your preferred editor, with debugger and all, while keeping all the setup of the bot's various dependencies inside Docker. * Append the following line to your `.env` file: `API_KEYS_SITE_API=badbot13m0n8f570f942013fc818f234916ca531`. -* In your `.env.server` file, set `urls_site` to `"localhost:8000"` and `urls_site_api` to `"localhost:8000/api"`. If you wish to keep using `web:8000/api` and `web:8000/api`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. +* In your `.env.server` file, set `urls_site` to `"localhost:8000"` and `urls_site_api` to `"localhost:8000/api"`. If you wish to keep using `web:8000` and `web:8000/api`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. * To work with snekbox, set `urls_snekbox_eval_api` to `"http://localhost:8060/eval"` and `urls_snekbox_311_eval_api` to `"http://localhost:8065/eval"` You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: -- cgit v1.2.3 From 22a66ebe50f8e52d5108ea629a34415fdc20cb6b Mon Sep 17 00:00:00 2001 From: shtlrs Date: Sat, 11 Mar 2023 22:27:05 +0100 Subject: delete dependency group orphan guide --- .../installing-project-dependencies.md | 24 ---------------------- 1 file changed, 24 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/installing-project-dependencies.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/installing-project-dependencies.md index 9ad24034..26d6de30 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/installing-project-dependencies.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/installing-project-dependencies.md @@ -39,27 +39,3 @@ $ poetry run task precommit ![PyCharm Poetry Environment](/static/images/content/contributing/pycharm_poetry.png)
      6. PyCharm will automatically install the packages required into a virtual environment.
      ![PyCharm Project Interpreter](/static/images/content/contributing/pycharm_poetry_success.png) - - -## Installing specific dependency groups - -In some cases, like when you'll be using the configuration bootstrapping script, you might need to install extra dependencies that are -not necessary for the bot project to function. -These dependencies are generally put in a separate optional group likewise: - -```text -[tool.poetry.group.group-name] -... -[tool.poetry.group.group-name.dependencies] -pytest = "^6.0.0" -pytest-mock = "*" -``` - -To install them, you simply need to run the following command ---- - -```shell -$ poetry install --only {{group-name}} -``` - ---- -- cgit v1.2.3 From 2ce0b6936e93271e1de7f772686fb5adff7bb770 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Mon, 13 Mar 2023 12:36:39 +0100 Subject: mention `BOT_PREFIX` in the appendix --- .../guides/pydis-guides/contributing/bot.md | 30 ++++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 18797ab5..795bf58f 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -51,9 +51,10 @@ otherwise a lot of it has to be done by hand which will be detailed in the [manu #### .env This file will mostly contain sensitive information such as your `BOT_TOKEN` and your `REDIS_PASSWORD`. -It will also contain configurations related to external services the bot might use such as `USE_METRICITY`, which are unrelated your server, with the only exception being `GUILD_ID`. +It will also contain configurations related to external services the bot might use such as `USE_METRICITY`, which are unrelated to your server, with the only exception being `GUILD_ID`. **Notes**: + * Both `.env` and `.env.server` are and should remain ignored by git, otherwise you risk pushing sensitive information. * Skip the following step if you would like to configure the bot manually, but that will require more work. @@ -391,19 +392,20 @@ If you find any bugs in the bot or would like to request a feature, feel free to ### Appendix: Full ENV File Options The following is a list of all available environment variables used by the bot: -| Variable | Required | Description | -|----------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `BOT_TOKEN` | Always | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)). | -| `GUILD_ID` | Always | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)). | -| `API_KEYS_SITE_API` | When running bot without Docker | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below. | -| `BOT_SENTRY_DSN` | When connecting the bot to sentry | The DSN of the sentry monitor. | -| `BOT_TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. | -| `DEBUG` | In production | `true` or `false`, depending on whether to enable debug mode, affecting the behavior of certain features. `true` by default. | -| `REDIS_PASSWORD` | When not using FakeRedis | The password to connect to the Redis database (see [Optional: Working with Redis](#optional-working-with-redis)). | -| `USE_METRICITY` | When using Metricity | `true` or `false`, depending on whether to enable metrics collection using Metricity (see [Optional: Working with Metricity](#optional-working-with-metricity)). `false` by default. | -| `API_KEYS_GITHUB` | When you wish to interact with GitHub | The API key to interact with GitHub, for example to download files for the branding manager. | -| `METABASE_USERNAME` | When you wish to interact with Metabase | The username for a Metabase admin account. | -| `METABASE_PASSWORD` | When you wish to interact with Metabase | The password for a Metabase admin account. | +| Variable | Required | Description | +|----------------------|---------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `BOT_TOKEN` | Always | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)). | +| `GUILD_ID` | Always | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)). | +| `BOT_PREFIX` | When you wish to use a prefix different than "!" | Your Discord bot command's prefix. | +| `API_KEYS_SITE_API` | When running bot without Docker | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below. | +| `BOT_SENTRY_DSN` | When connecting the bot to sentry | The DSN of the sentry monitor. | +| `BOT_TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. | +| `DEBUG` | In production | `true` or `false`, depending on whether to enable debug mode, affecting the behavior of certain features. `true` by default. | +| `REDIS_PASSWORD` | When not using FakeRedis | The password to connect to the Redis database (see [Optional: Working with Redis](#optional-working-with-redis)). | +| `USE_METRICITY` | When using Metricity | `true` or `false`, depending on whether to enable metrics collection using Metricity (see [Optional: Working with Metricity](#optional-working-with-metricity)). `false` by default. | +| `API_KEYS_GITHUB` | When you wish to interact with GitHub | The API key to interact with GitHub, for example to download files for the branding manager. | +| `METABASE_USERNAME` | When you wish to interact with Metabase | The username for a Metabase admin account. | +| `METABASE_PASSWORD` | When you wish to interact with Metabase | The password for a Metabase admin account. | --- -- cgit v1.2.3 From 204f7c10ba950ef3758b0eb1b855ae36631c6002 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Mon, 13 Mar 2023 13:18:39 +0100 Subject: add hyperlink to open an issue --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 795bf58f..04b78f1c 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -385,7 +385,7 @@ If you want to run the bot locally, you can run `docker-compose up metricity` in ### Issues? If you have any issues with setting up the bot, come discuss it with us on the [#dev-contrib](https://discord.gg/2h3qBv8Xaa) channel on our server. -If you find any bugs in the bot or would like to request a feature, feel free to open an issue on the repository. +If you find any bugs in the bot or would like to request a feature, feel free to [open an issue](https://github.com/python-discord/bot/issues/new/choose) on the repository. --- -- cgit v1.2.3 From ca8a7a6f06db8e73a41f4bcae19b704b11c2c98f Mon Sep 17 00:00:00 2001 From: shtlrs Date: Tue, 21 Mar 2023 16:03:57 +0100 Subject: add rule 10 to the list --- pydis_site/apps/api/views.py | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'pydis_site/apps') 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"] + ), ]) -- cgit v1.2.3 From 147f21e962e27897517700c5b706a8cbb80f7a18 Mon Sep 17 00:00:00 2001 From: brad90four <42116429+brad90four@users.noreply.github.com> Date: Tue, 21 Mar 2023 12:54:55 -0400 Subject: Add Rule 10 for ChatGPT answers (#910) --- pydis_site/apps/content/resources/rules.md | 1 + 1 file changed, 1 insertion(+) (limited to 'pydis_site/apps') 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 -- cgit v1.2.3 From 799efdfd58f028f4e3eb85649609acacd5ce6e51 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 21 Mar 2023 17:55:32 +0100 Subject: Update docs for upstream documentation change (#905) Fixes #900. --- .../guides/pydis-guides/contributing/creating-bot-account.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') 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 `` 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 `` 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=&permissions=8&scope=bot +https://discordapp.com/api/oauth2/authorize?client_id=&permissions=8&scope=bot ``` Optionally, you can generate your own invite url in the `OAuth` tab, after selecting `bot` as the scope. -- cgit v1.2.3 From 2ce0a634254f697190bca782ef641a32deb19c03 Mon Sep 17 00:00:00 2001 From: Diabolical5777 <84365102+Diabolical5777@users.noreply.github.com> Date: Wed, 22 Mar 2023 00:53:17 +0400 Subject: Subclassing context.md (#703) Co-authored-by: Johannes Christ Co-authored-by: Amrou Bellalouna Co-authored-by: Bluenix Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> Co-authored-by: wookie184 --- .../python-guides/discordpy-subclassing-context.md | 129 +++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 pydis_site/apps/content/resources/guides/python-guides/discordpy-subclassing-context.md (limited to 'pydis_site/apps') 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, ...) + + +@bot.command() +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 +``` -- cgit v1.2.3 From a81e3d65ec63e668cc5aa6bb250ff4be1d574181 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 21 Mar 2023 22:18:33 +0100 Subject: Update mention of the partners channel Fixes #868. --- pydis_site/apps/content/resources/server-info/roles.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'pydis_site/apps') 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 ### 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. ### Python Community **Description:** Prominent people in the Python ecosystem. -- cgit v1.2.3 From 71208aa5ccd9606033227a91ee08a39148258ceb Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 21 Mar 2023 23:13:55 +0100 Subject: Add a README for the events app --- pydis_site/apps/events/README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 pydis_site/apps/events/README.md (limited to 'pydis_site/apps') 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). -- cgit v1.2.3 From 138506045acd1cf464dc63d4465d36208801a3ab Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 25 Mar 2023 18:15:56 +0300 Subject: Make the unique constraint reversible Co-authored-by: Amrou --- pydis_site/apps/api/migrations/0089_unique_constraint_filters.py | 1 + 1 file changed, 1 insertion(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py index d6f32342..0bcfd8a3 100644 --- a/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py +++ b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py @@ -12,6 +12,7 @@ class Migration(migrations.Migration): "ALTER TABLE api_filter " "ADD CONSTRAINT unique_filters UNIQUE NULLS NOT DISTINCT " "(content, additional_field, 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', -- cgit v1.2.3 From d6565845ec07b9d76949da36505b3f53e402d230 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 25 Mar 2023 18:29:31 +0300 Subject: Documentation improvements and fixes Co-authored-by: Amrou --- pydis_site/apps/api/migrations/0088_new_filter_schema.py | 4 ++-- pydis_site/apps/api/migrations/0091_antispam_filter_list.py | 2 +- pydis_site/apps/api/models/bot/filters.py | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0088_new_filter_schema.py b/pydis_site/apps/api/migrations/0088_new_filter_schema.py index 46756781..1506e4d7 100644 --- a/pydis_site/apps/api/migrations/0088_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0088_new_filter_schema.py @@ -118,7 +118,7 @@ class Migration(migrations.Migration): ('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.", null=True, size=None)), + ('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)), @@ -145,7 +145,7 @@ class Migration(migrations.Migration): ('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.", size=None)), + ('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)), diff --git a/pydis_site/apps/api/migrations/0091_antispam_filter_list.py b/pydis_site/apps/api/migrations/0091_antispam_filter_list.py index 58ffa4a4..7c233142 100644 --- a/pydis_site/apps/api/migrations/0091_antispam_filter_list.py +++ b/pydis_site/apps/api/migrations/0091_antispam_filter_list.py @@ -7,7 +7,7 @@ import pydis_site.apps.api.models.bot.filters def create_antispam_list(apps: Apps, _): - """Create the 'unique' FilterList and its related Filters.""" + """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") diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 584ee726..60ae394b 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -101,7 +101,7 @@ class FilterList(ModelTimestampMixin, ModelReprMixin, models.Model): ) disabled_channels = ArrayField( models.CharField(max_length=100), - help_text="Channels in which to not run the filter." + 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), @@ -216,7 +216,8 @@ class FilterBase(ModelTimestampMixin, ModelReprMixin, models.Model): ) disabled_channels = ArrayField( models.CharField(max_length=100), - help_text="Channels in which to not run the filter.", null=True + 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), -- cgit v1.2.3 From 43913623f87329b51cd2a6793e843c30368698aa Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 25 Mar 2023 18:51:24 +0300 Subject: Merge the extra kwargs creation functions Co-authored-by: Amrou --- pydis_site/apps/api/serializers.py | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index fe3c1dd2..e8c5869f 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -211,11 +211,11 @@ CHANNEL_SCOPE_FIELDS = ( MENTIONS_FIELDS = ("guild_pings", "dm_pings") -def _create_filter_meta_extra_kwargs() -> dict[str, dict[str, bool]]: - """Create the extra kwargs of the Filter serializer's Meta class.""" +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} + 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: @@ -278,7 +278,7 @@ class FilterSerializer(ModelSerializer): 'additional_field', 'filter_list' ) + SETTINGS_FIELDS - extra_kwargs = _create_filter_meta_extra_kwargs() + 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.""" @@ -317,19 +317,6 @@ class FilterSerializer(ModelSerializer): return schema -def _create_filter_list_meta_extra_kwargs() -> dict[str, dict[str, bool]]: - """Create the extra kwargs of the FilterList serializer's Meta class.""" - extra_kwargs = {} - for field in SETTINGS_FIELDS: - field_args = {} - 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 - - class FilterListSerializer(ModelSerializer): """A class providing (de-)serialization of `FilterList` instances.""" @@ -367,7 +354,7 @@ class FilterListSerializer(ModelSerializer): fields = ( 'id', 'created_at', 'updated_at', 'name', 'list_type', 'filters' ) + SETTINGS_FIELDS - extra_kwargs = _create_filter_list_meta_extra_kwargs() + extra_kwargs = _create_meta_extra_kwargs(for_filter=False) # Ensure there can only be one filter list with the same name and type. validators = [ -- cgit v1.2.3 From 36bca58ff336f9d4b797a2c76f08775f9de7e9a7 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 25 Mar 2023 21:26:07 +0300 Subject: Specify the common elements in the validation errors Co-authored-by: Amrou --- pydis_site/apps/api/serializers.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index e8c5869f..bfad18ab 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -247,20 +247,24 @@ class FilterSerializer(ModelSerializer): "Infraction type is required with infraction duration or reason." ) - if ( + 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." + "You can't have the same value in both enabled and disabled channels lists:" + f" {', '.join(repr(channel) for channel in common_channels)}." ) - if ( + 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." + "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 @@ -333,17 +337,23 @@ class FilterListSerializer(ModelSerializer): data.get('disabled_channels') is not None and data.get('enabled_channels') is not None ): - channels_collection = data['disabled_channels'] + data['enabled_channels'] - if len(channels_collection) != len(set(channels_collection)): - raise ValidationError("Enabled and Disabled channels lists contain duplicates.") + 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('disabled_categories') is not None and data.get('enabled_categories') is not None ): - categories_collection = data['disabled_categories'] + data['enabled_categories'] - if len(categories_collection) != len(set(categories_collection)): - raise ValidationError("Enabled and Disabled categories lists contain duplicates.") + 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 -- cgit v1.2.3 From 91b89475913210400cf39884efe37ab5552efbf7 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 25 Mar 2023 21:35:49 +0300 Subject: Use consistent quoting style Co-authored-by: Johannes Christ --- pydis_site/apps/api/models/bot/filters.py | 4 +- pydis_site/apps/api/serializers.py | 68 +++++++++++++++---------------- 2 files changed, 36 insertions(+), 36 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 60ae394b..c6f6f851 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -253,8 +253,8 @@ class Filter(FilterBase): constraints = ( UniqueConstraint( fields=tuple( - [field.name for field in FilterBase._meta.fields - if field.name not in ("id", "description", "created_at", "updated_at")] + field.name for field in FilterBase._meta.fields + if field.name not in ("id", "description", "created_at", "updated_at") ), name="unique_filters"), ) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index bfad18ab..da02c837 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -188,27 +188,27 @@ BASE_FILTER_FIELDS = ( ) BASE_FILTERLIST_FIELDS = ('id', 'created_at', 'updated_at', 'name', 'list_type') BASE_SETTINGS_FIELDS = ( - "bypass_roles", - "filter_dm", - "enabled", - "remove_context", - "send_alert" + '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" + 'infraction_type', + 'infraction_reason', + 'infraction_duration', + 'infraction_channel', + 'dm_content', + 'dm_embed' ) CHANNEL_SCOPE_FIELDS = ( - "disabled_channels", - "disabled_categories", - "enabled_channels", - "enabled_categories" + 'disabled_channels', + 'disabled_categories', + 'enabled_channels', + 'enabled_categories' ) -MENTIONS_FIELDS = ("guild_pings", "dm_pings") +MENTIONS_FIELDS = ('guild_pings', 'dm_pings') def _create_meta_extra_kwargs(*, for_filter: bool) -> dict[str, dict[str, bool]]: @@ -228,7 +228,7 @@ 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) + return getattr(data['filter_list'], field_name) class FilterSerializer(ModelSerializer): @@ -238,18 +238,18 @@ class FilterSerializer(ModelSerializer): """Perform infraction data + allowed and disallowed lists validation.""" if ( ( - get_field_value(data, "infraction_reason") - or get_field_value(data, "infraction_duration") + get_field_value(data, 'infraction_reason') + or get_field_value(data, 'infraction_duration') ) - and get_field_value(data, "infraction_type") == "NONE" + and get_field_value(data, 'infraction_type') == 'NONE' ): raise ValidationError( "Infraction type is required with infraction duration or reason." ) common_channels = ( - set(get_field_value(data, "disabled_channels")) - & set(get_field_value(data, "enabled_channels")) + set(get_field_value(data, 'disabled_channels')) + & set(get_field_value(data, 'enabled_channels')) ) if common_channels: raise ValidationError( @@ -258,8 +258,8 @@ class FilterSerializer(ModelSerializer): ) common_categories = ( - set(get_field_value(data, "disabled_categories")) - & set(get_field_value(data, "enabled_categories")) + set(get_field_value(data, 'disabled_categories')) + & set(get_field_value(data, 'enabled_categories')) ) if common_categories: raise ValidationError( @@ -305,19 +305,19 @@ class FilterSerializer(ModelSerializer): into a sub-field called `settings`. """ settings = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} - settings["infraction_and_notification"] = { + settings['infraction_and_notification'] = { name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS } - settings["channel_scope"] = { + settings['channel_scope'] = { name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS } - settings["mentions"] = { + 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 + schema['filter_list'] = instance.filter_list.id + schema['settings'] = settings return schema @@ -388,19 +388,19 @@ class FilterListSerializer(ModelSerializer): into a sub-field called `settings`. """ schema = {name: getattr(instance, name) for name in BASE_FILTERLIST_FIELDS} - schema["filters"] = [ + 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"] = { + 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} + 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 + schema['settings'] = settings return schema # endregion -- cgit v1.2.3 From feb54974056578836de841971a953f2cd206ce80 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 22 Mar 2023 23:19:44 +0100 Subject: Drop dependency to pyfakefs Create a temporary directory to manage our resource tests instead of reyling on pyfakefs to mock it away for us. This also makes the code more portable: all we need now is a way to create a temporary directory. `pathlib` mostly abstracts away the other parts for us. Since we're well-behaved, we clean up the temporary directory at the end of the Python interpreter's life using `atexit` and `shutil.rmtree`. This PR was written and tested with Python 3.9 which required some hacks in `pyproject.toml` to make it work, it may require re-locking if CI throws up. Closes #679. --- poetry.lock | 50 ++++++++++++++++++------- pydis_site/apps/content/tests/helpers.py | 63 +++++++++++++++++--------------- pyproject.toml | 1 - 3 files changed, 70 insertions(+), 44 deletions(-) (limited to 'pydis_site/apps') diff --git a/poetry.lock b/poetry.lock index c5dad711..7fad2146 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.1 and should not be changed by hand. [[package]] name = "anyio" @@ -825,6 +825,26 @@ files = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] +[[package]] +name = "importlib-metadata" +version = "6.1.0" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "importlib_metadata-6.1.0-py3-none-any.whl", hash = "sha256:ff80f3b5394912eb1b108fcfd444dc78b7f1f3e16b16188054bd01cb9cb86f09"}, + {file = "importlib_metadata-6.1.0.tar.gz", hash = "sha256:43ce9281e097583d758c2c708c4376371261a02c34682491a8e98352365aad20"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] + [[package]] name = "libsass" version = "0.22.0" @@ -1121,18 +1141,6 @@ snowballstemmer = ">=2.2.0" [package.extras] toml = ["tomli (>=1.2.3)"] -[[package]] -name = "pyfakefs" -version = "5.1.0" -description = "pyfakefs implements a fake file system that mocks the Python file system modules." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pyfakefs-5.1.0-py3-none-any.whl", hash = "sha256:e6f34a8224b41f1b1ab25aa8d430121dac42e3c6e981e01eae76b3343fba47d0"}, - {file = "pyfakefs-5.1.0.tar.gz", hash = "sha256:316c6026640d14a6b4fbde71fd9674576d1b5710deda8fabde8aad51d785dbc3"}, -] - [[package]] name = "pyflakes" version = "3.0.1" @@ -1534,6 +1542,22 @@ files = [ [package.extras] brotli = ["Brotli"] +[[package]] +name = "zipp" +version = "3.15.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + [metadata] lock-version = "2.0" python-versions = "3.10.*" diff --git a/pydis_site/apps/content/tests/helpers.py b/pydis_site/apps/content/tests/helpers.py index d897c024..1eab62be 100644 --- a/pydis_site/apps/content/tests/helpers.py +++ b/pydis_site/apps/content/tests/helpers.py @@ -1,12 +1,12 @@ +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-')) # Valid markdown content with YAML metadata @@ -50,7 +50,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 +75,32 @@ 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) + + @classmethod + def setUpTestData(cls): + # May get called multiple times - ignore erorrs in that case. + atexit.register(shutil.rmtree, BASE_PATH, ignore_errors=True) diff --git a/pyproject.toml b/pyproject.toml index 21bc26d7..21857f84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,6 @@ pre-commit = "3.2.0" [tool.poetry.group.test.dependencies] coverage = "7.2.2" -pyfakefs = "5.1.0" [build-system] requires = ["poetry-core>=1.2.0"] -- cgit v1.2.3 From 22c653a7179f97e4f7c4de391f9204d71f4fb6c7 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 24 Mar 2023 22:55:32 +0100 Subject: Register cleanup job after module load --- pydis_site/apps/content/tests/helpers.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/tests/helpers.py b/pydis_site/apps/content/tests/helpers.py index 1eab62be..fad91050 100644 --- a/pydis_site/apps/content/tests/helpers.py +++ b/pydis_site/apps/content/tests/helpers.py @@ -7,6 +7,7 @@ from django.test import TestCase 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 @@ -99,8 +100,3 @@ class MockPagesTestCase(TestCase): 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) - - @classmethod - def setUpTestData(cls): - # May get called multiple times - ignore erorrs in that case. - atexit.register(shutil.rmtree, BASE_PATH, ignore_errors=True) -- cgit v1.2.3 From 0f40b114940164c65b10d1312b5a419ce025c799 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 26 Mar 2023 23:12:34 +0300 Subject: Rename additional_field to additional_settings --- pydis_site/apps/api/migrations/0088_new_filter_schema.py | 4 ++-- .../apps/api/migrations/0089_unique_constraint_filters.py | 4 ++-- pydis_site/apps/api/models/bot/filters.py | 4 +++- pydis_site/apps/api/serializers.py | 9 +++++---- pydis_site/apps/api/tests/test_filters.py | 2 +- pydis_site/apps/api/tests/test_models.py | 2 +- pydis_site/apps/api/viewsets/bot/filters.py | 14 +++++++------- 7 files changed, 21 insertions(+), 18 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0088_new_filter_schema.py b/pydis_site/apps/api/migrations/0088_new_filter_schema.py index 1506e4d7..2e1d78c9 100644 --- a/pydis_site/apps/api/migrations/0088_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0088_new_filter_schema.py @@ -64,7 +64,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: updated_at=object_.updated_at, filter_list=list_, description=object_.comment, - additional_field=None, + additional_settings=None, guild_pings=None, filter_dm=None, dm_pings=None, @@ -105,7 +105,7 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField(auto_now=True)), ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)), - ('additional_field', models.JSONField(help_text='Implementation specific field.', null=True)), + ('additional_settings', models.JSONField(help_text='Additional settings which are specific to this filter.', null=True)), ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, null=True)), ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, null=True)), diff --git a/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py index 0bcfd8a3..cb230a27 100644 --- a/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py +++ b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py @@ -11,13 +11,13 @@ class Migration(migrations.Migration): migrations.RunSQL( "ALTER TABLE api_filter " "ADD CONSTRAINT unique_filters UNIQUE NULLS NOT DISTINCT " - "(content, additional_field, 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)", + "(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_field', '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'), + 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/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index c6f6f851..aadb39aa 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -131,7 +131,9 @@ class FilterBase(ModelTimestampMixin, ModelReprMixin, models.Model): max_length=200, help_text="Why this filter has been added.", null=True ) - additional_field = models.JSONField(null=True, help_text="Implementation specific field.") + additional_settings = models.JSONField( + null=True, help_text="Additional settings which are specific to this filter." + ) filter_list = models.ForeignKey( FilterList, models.CASCADE, related_name="filters", help_text="The filter list containing this filter." diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index da02c837..a3779094 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -184,7 +184,7 @@ ALLOW_EMPTY_SETTINGS = ( # Required fields for custom JSON representation purposes BASE_FILTER_FIELDS = ( - 'id', 'created_at', 'updated_at', 'content', 'description', 'additional_field' + 'id', 'created_at', 'updated_at', 'content', 'description', 'additional_settings' ) BASE_FILTERLIST_FIELDS = ('id', 'created_at', 'updated_at', 'name', 'list_type') BASE_SETTINGS_FIELDS = ( @@ -279,7 +279,7 @@ class FilterSerializer(ModelSerializer): 'updated_at', 'content', 'description', - 'additional_field', + 'additional_settings', 'filter_list' ) + SETTINGS_FIELDS extra_kwargs = _create_meta_extra_kwargs(for_filter=True) @@ -382,9 +382,10 @@ class FilterListSerializer(ModelSerializer): Provides a custom JSON representation to the FilterList Serializers. This representation restructures how the Filter is represented. - It groups the Infraction, Channel and Mention related fields into their own separated group. + It groups the Infraction, Channel, and Mention related fields + into their own separated groups. - Furthermore, it puts the fields that meant to represent FilterList settings, + 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} diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py index f36e0617..3d3be51e 100644 --- a/pydis_site/apps/api/tests/test_filters.py +++ b/pydis_site/apps/api/tests/test_filters.py @@ -92,7 +92,7 @@ def get_test_sequences() -> Dict[str, TestSequence]: { "content": "bad word", "description": "This is a really bad word.", - "additional_field": "{'hi': 'there'}", + "additional_settings": "{'hi': 'there'}", "guild_pings": None, "filter_dm": None, "dm_pings": None, diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index 25d771cc..d3341b35 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -113,7 +113,7 @@ class StringDunderMethodTests(SimpleTestCase): Filter( content="ducky_nsfw", description="This ducky is totally inappropriate!", - additional_field=None, + additional_settings=None, ), OffensiveMessage( id=602951077675139072, diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index c84da909..d6c2d18c 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -33,7 +33,7 @@ class FilterListViewSet(ModelViewSet): ... "updated_at": "2023-01-27T21:26:34.030532Z", ... "content": "267624335836053506", ... "description": "Python Discord", - ... "additional_field": None, + ... "additional_settings": None, ... "filter_list": 1, ... "settings": { ... "bypass_roles": None, @@ -119,7 +119,7 @@ class FilterListViewSet(ModelViewSet): ... "updated_at": "2023-01-27T21:26:34.030532Z", ... "content": "267624335836053506", ... "description": "Python Discord", - ... "additional_field": None, + ... "additional_settings": None, ... "filter_list": 1, ... "settings": { ... "bypass_roles": None, @@ -239,7 +239,7 @@ class FilterListViewSet(ModelViewSet): ... "updated_at": "2023-01-27T21:26:34.030532Z", ... "content": "267624335836053506", ... "description": "Python Discord", - ... "additional_field": None, + ... "additional_settings": None, ... "filter_list": 1, ... "settings": { ... "bypass_roles": None, @@ -334,7 +334,7 @@ class FilterViewSet(ModelViewSet): ... "updated_at": "2023-01-27T21:26:34.030532Z", ... "content": "267624335836053506", ... "description": "Python Discord", - ... "additional_field": None, + ... "additional_settings": None, ... "filter_list": 1, ... "settings": { ... "bypass_roles": None, @@ -379,7 +379,7 @@ class FilterViewSet(ModelViewSet): ... "updated_at": "2023-01-27T21:26:34.030532Z", ... "content": "267624335836053506", ... "description": "Python Discord", - ... "additional_field": None, + ... "additional_settings": None, ... "filter_list": 1, ... "settings": { ... "bypass_roles": None, @@ -420,7 +420,7 @@ class FilterViewSet(ModelViewSet): ... "filter_list": 1, ... "content": "267624335836053506", ... "description": "Python Discord", - ... "additional_field": None, + ... "additional_settings": None, ... "bypass_roles": None, ... "filter_dm": None, ... "enabled": False, @@ -454,7 +454,7 @@ class FilterViewSet(ModelViewSet): ... "updated_at": "2023-01-27T21:26:34.030532Z", ... "content": "267624335836053506", ... "description": "Python Discord", - ... "additional_field": None, + ... "additional_settings": None, ... "filter_list": 1, ... "settings": { ... "bypass_roles": None, -- cgit v1.2.3 From 621280892b96875beb5053cbd07a837f1833310a Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 29 Mar 2023 00:58:27 +0200 Subject: Add a readme for the home app This commit also moves the nested structures for models and views in the home app into a single module, as they were not split up as part of the subpackage, with the goal of making this a bit more overseeable. Part of #674. --- .coveragerc | 2 +- pydis_site/apps/home/README.md | 35 +++++ pydis_site/apps/home/models.py | 33 +++++ pydis_site/apps/home/models/__init__.py | 3 - pydis_site/apps/home/models/repository_metadata.py | 33 ----- pydis_site/apps/home/views.py | 163 +++++++++++++++++++++ pydis_site/apps/home/views/__init__.py | 3 - pydis_site/apps/home/views/home.py | 163 --------------------- 8 files changed, 232 insertions(+), 203 deletions(-) create mode 100644 pydis_site/apps/home/README.md create mode 100644 pydis_site/apps/home/models.py delete mode 100644 pydis_site/apps/home/models/__init__.py delete mode 100644 pydis_site/apps/home/models/repository_metadata.py create mode 100644 pydis_site/apps/home/views.py delete mode 100644 pydis_site/apps/home/views/__init__.py delete mode 100644 pydis_site/apps/home/views/home.py (limited to 'pydis_site/apps') diff --git a/.coveragerc b/.coveragerc index 039654db..38926b22 100644 --- a/.coveragerc +++ b/.coveragerc @@ -14,7 +14,7 @@ omit = pydis_site/wsgi.py pydis_site/settings.py pydis_site/utils/resources.py - pydis_site/apps/home/views/home.py + pydis_site/apps/home/views.py [report] fail_under = 100 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.py b/pydis_site/apps/home/models.py new file mode 100644 index 00000000..00a83cd7 --- /dev/null +++ b/pydis_site/apps/home/models.py @@ -0,0 +1,33 @@ +from django.db import models + + +class RepositoryMetadata(models.Model): + """Information about one of our repos fetched from the GitHub API.""" + + last_updated = models.DateTimeField( + help_text="The date and time this data was last fetched.", + auto_now=True, + ) + repo_name = models.CharField( + primary_key=True, + max_length=40, + help_text="The full name of the repo, e.g. python-discord/site", + ) + description = models.CharField( + max_length=400, + help_text="The description of the repo.", + ) + forks = models.IntegerField( + help_text="The number of forks of this repo", + ) + stargazers = models.IntegerField( + help_text="The number of stargazers for this repo", + ) + language = models.CharField( + max_length=20, + help_text="The primary programming language used for this repo.", + ) + + def __str__(self): + """Returns the repo name, for display purposes.""" + return self.repo_name 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/models/repository_metadata.py b/pydis_site/apps/home/models/repository_metadata.py deleted file mode 100644 index 00a83cd7..00000000 --- a/pydis_site/apps/home/models/repository_metadata.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.db import models - - -class RepositoryMetadata(models.Model): - """Information about one of our repos fetched from the GitHub API.""" - - last_updated = models.DateTimeField( - help_text="The date and time this data was last fetched.", - auto_now=True, - ) - repo_name = models.CharField( - primary_key=True, - max_length=40, - help_text="The full name of the repo, e.g. python-discord/site", - ) - description = models.CharField( - max_length=400, - help_text="The description of the repo.", - ) - forks = models.IntegerField( - help_text="The number of forks of this repo", - ) - stargazers = models.IntegerField( - help_text="The number of stargazers for this repo", - ) - language = models.CharField( - max_length=20, - help_text="The primary programming language used for this repo.", - ) - - def __str__(self): - """Returns the repo name, for display purposes.""" - return self.repo_name diff --git a/pydis_site/apps/home/views.py b/pydis_site/apps/home/views.py new file mode 100644 index 00000000..8a165682 --- /dev/null +++ b/pydis_site/apps/home/views.py @@ -0,0 +1,163 @@ +import logging +from typing import Dict, List + +import httpx +from django.core.handlers.wsgi import WSGIRequest +from django.http import HttpResponse +from django.shortcuts import render +from django.utils import timezone +from django.views import View + +from pydis_site import settings +from pydis_site.apps.home.models import RepositoryMetadata + +log = logging.getLogger(__name__) + + +class HomeView(View): + """The main landing page for the website.""" + + github_api = "https://api.github.com/users/python-discord/repos?per_page=100" + repository_cache_ttl = 3600 + + # Which of our GitHub repos should be displayed on the front page, and in which order? + repos = [ + "python-discord/site", + "python-discord/bot", + "python-discord/snekbox", + "python-discord/sir-lancebot", + "python-discord/metricity", + "python-discord/king-arthur", + ] + + def __init__(self): + """Clean up stale RepositoryMetadata.""" + if not settings.STATIC_BUILD: + RepositoryMetadata.objects.exclude(repo_name__in=self.repos).delete() + + # If no token is defined (for example in local development), then + # it does not make sense to pass the Authorization header. More + # specifically, GitHub will reject any requests from us due to the + # invalid header. We can make a limited number of anonymous requests + # though, which is useful for testing. + if settings.GITHUB_TOKEN: + self.headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"} + else: + self.headers = {} + + def _get_api_data(self) -> Dict[str, Dict[str, str]]: + """ + Call the GitHub API and get information about our repos. + + If we're unable to get that info for any reason, return an empty dict. + """ + repo_dict = {} + try: + # Fetch the data from the GitHub API + api_data: List[dict] = httpx.get( + self.github_api, + headers=self.headers, + timeout=settings.TIMEOUT_PERIOD + ).json() + except httpx.TimeoutException: + log.error("Request to fetch GitHub repository metadata for timed out!") + return repo_dict + + # Process the API data into our dict + for repo in api_data: + try: + full_name = repo["full_name"] + + if full_name in self.repos: + repo_dict[full_name] = { + "full_name": repo["full_name"], + "description": repo["description"], + "language": repo["language"], + "forks_count": repo["forks_count"], + "stargazers_count": repo["stargazers_count"], + } + # Something is not right about the API data we got back from GitHub. + except (TypeError, ConnectionError, KeyError) as e: + log.error( + "Unable to parse the GitHub repository metadata from response!", + extra={ + 'api_data': api_data, + 'error': e + } + ) + continue + + return repo_dict + + def _get_repo_data(self) -> List[RepositoryMetadata]: + """Build a list of RepositoryMetadata objects that we can use to populate the front page.""" + # First off, load the timestamp of the least recently updated entry. + if settings.STATIC_BUILD: + last_update = None + else: + last_update = ( + RepositoryMetadata.objects.values_list("last_updated", flat=True) + .order_by("last_updated").first() + ) + + # If we did not retrieve any results here, we should import them! + if last_update is None: + + # Try to get new data from the API. If it fails, we'll return an empty list. + # In this case, we simply don't display our projects on the site. + api_repositories = self._get_api_data() + + # Create all the repodata records in the database. + data = [ + RepositoryMetadata( + repo_name=api_data["full_name"], + description=api_data["description"], + forks=api_data["forks_count"], + stargazers=api_data["stargazers_count"], + language=api_data["language"], + ) + for api_data in api_repositories.values() + ] + + if settings.STATIC_BUILD: + return data + else: + return RepositoryMetadata.objects.bulk_create(data) + + # If the data is stale, we should refresh it. + if (timezone.now() - last_update).seconds > self.repository_cache_ttl: + # Try to get new data from the API. If it fails, return the cached data. + api_repositories = self._get_api_data() + + if not api_repositories: + return RepositoryMetadata.objects.all() + + # Update or create all RepoData objects in self.repos + database_repositories = [] + for api_data in api_repositories.values(): + repo_data, _created = RepositoryMetadata.objects.update_or_create( + repo_name=api_data["full_name"], + defaults={ + 'repo_name': api_data["full_name"], + 'description': api_data["description"], + 'forks': api_data["forks_count"], + 'stargazers': api_data["stargazers_count"], + 'language': api_data["language"], + } + ) + database_repositories.append(repo_data) + return database_repositories + + # Otherwise, if the data is fresher than 2 minutes old, we should just return it. + else: + return RepositoryMetadata.objects.all() + + def get(self, request: WSGIRequest) -> HttpResponse: + """Collect repo data and render the homepage view.""" + repo_data = self._get_repo_data() + return render(request, "home/index.html", {"repo_data": repo_data}) + + +def timeline(request: WSGIRequest) -> HttpResponse: + """Render timeline view.""" + return render(request, 'home/timeline.html') 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/home/views/home.py b/pydis_site/apps/home/views/home.py deleted file mode 100644 index 8a165682..00000000 --- a/pydis_site/apps/home/views/home.py +++ /dev/null @@ -1,163 +0,0 @@ -import logging -from typing import Dict, List - -import httpx -from django.core.handlers.wsgi import WSGIRequest -from django.http import HttpResponse -from django.shortcuts import render -from django.utils import timezone -from django.views import View - -from pydis_site import settings -from pydis_site.apps.home.models import RepositoryMetadata - -log = logging.getLogger(__name__) - - -class HomeView(View): - """The main landing page for the website.""" - - github_api = "https://api.github.com/users/python-discord/repos?per_page=100" - repository_cache_ttl = 3600 - - # Which of our GitHub repos should be displayed on the front page, and in which order? - repos = [ - "python-discord/site", - "python-discord/bot", - "python-discord/snekbox", - "python-discord/sir-lancebot", - "python-discord/metricity", - "python-discord/king-arthur", - ] - - def __init__(self): - """Clean up stale RepositoryMetadata.""" - if not settings.STATIC_BUILD: - RepositoryMetadata.objects.exclude(repo_name__in=self.repos).delete() - - # If no token is defined (for example in local development), then - # it does not make sense to pass the Authorization header. More - # specifically, GitHub will reject any requests from us due to the - # invalid header. We can make a limited number of anonymous requests - # though, which is useful for testing. - if settings.GITHUB_TOKEN: - self.headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"} - else: - self.headers = {} - - def _get_api_data(self) -> Dict[str, Dict[str, str]]: - """ - Call the GitHub API and get information about our repos. - - If we're unable to get that info for any reason, return an empty dict. - """ - repo_dict = {} - try: - # Fetch the data from the GitHub API - api_data: List[dict] = httpx.get( - self.github_api, - headers=self.headers, - timeout=settings.TIMEOUT_PERIOD - ).json() - except httpx.TimeoutException: - log.error("Request to fetch GitHub repository metadata for timed out!") - return repo_dict - - # Process the API data into our dict - for repo in api_data: - try: - full_name = repo["full_name"] - - if full_name in self.repos: - repo_dict[full_name] = { - "full_name": repo["full_name"], - "description": repo["description"], - "language": repo["language"], - "forks_count": repo["forks_count"], - "stargazers_count": repo["stargazers_count"], - } - # Something is not right about the API data we got back from GitHub. - except (TypeError, ConnectionError, KeyError) as e: - log.error( - "Unable to parse the GitHub repository metadata from response!", - extra={ - 'api_data': api_data, - 'error': e - } - ) - continue - - return repo_dict - - def _get_repo_data(self) -> List[RepositoryMetadata]: - """Build a list of RepositoryMetadata objects that we can use to populate the front page.""" - # First off, load the timestamp of the least recently updated entry. - if settings.STATIC_BUILD: - last_update = None - else: - last_update = ( - RepositoryMetadata.objects.values_list("last_updated", flat=True) - .order_by("last_updated").first() - ) - - # If we did not retrieve any results here, we should import them! - if last_update is None: - - # Try to get new data from the API. If it fails, we'll return an empty list. - # In this case, we simply don't display our projects on the site. - api_repositories = self._get_api_data() - - # Create all the repodata records in the database. - data = [ - RepositoryMetadata( - repo_name=api_data["full_name"], - description=api_data["description"], - forks=api_data["forks_count"], - stargazers=api_data["stargazers_count"], - language=api_data["language"], - ) - for api_data in api_repositories.values() - ] - - if settings.STATIC_BUILD: - return data - else: - return RepositoryMetadata.objects.bulk_create(data) - - # If the data is stale, we should refresh it. - if (timezone.now() - last_update).seconds > self.repository_cache_ttl: - # Try to get new data from the API. If it fails, return the cached data. - api_repositories = self._get_api_data() - - if not api_repositories: - return RepositoryMetadata.objects.all() - - # Update or create all RepoData objects in self.repos - database_repositories = [] - for api_data in api_repositories.values(): - repo_data, _created = RepositoryMetadata.objects.update_or_create( - repo_name=api_data["full_name"], - defaults={ - 'repo_name': api_data["full_name"], - 'description': api_data["description"], - 'forks': api_data["forks_count"], - 'stargazers': api_data["stargazers_count"], - 'language': api_data["language"], - } - ) - database_repositories.append(repo_data) - return database_repositories - - # Otherwise, if the data is fresher than 2 minutes old, we should just return it. - else: - return RepositoryMetadata.objects.all() - - def get(self, request: WSGIRequest) -> HttpResponse: - """Collect repo data and render the homepage view.""" - repo_data = self._get_repo_data() - return render(request, "home/index.html", {"repo_data": repo_data}) - - -def timeline(request: WSGIRequest) -> HttpResponse: - """Render timeline view.""" - return render(request, 'home/timeline.html') -- cgit v1.2.3 From d502ec4296e364dcbec86e2ab79c8980d35149a1 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Sat, 1 Apr 2023 21:20:37 +0200 Subject: lose all references to site and replace with the suitable changes --- .../resources/guides/pydis-guides/contributing/bot.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 04b78f1c..6f2c7e74 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -170,7 +170,6 @@ If you wish to set all values in your `env.server` for your testing server, you Additionally: * At this stage, set `redis_use_fakeredis` to `true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). -* Set `urls_site_schema` and `urls_site_api_schema` to `"http://"`. We understand this is tedious which is why we **heavily recommend** using the [automatic configuration setup](#automatic-configuration) @@ -224,11 +223,12 @@ You are now almost ready to run the Python bot. The simplest way to do so is wit In your `.env.server` file: -* Set `urls_site` to `"web:8000"` and `urls_site_api` to `"web:8000/api"`. -* If you wish to work with snekbox set the following: +* If you wish to work with snekbox, set the following: * `urls_snekbox_eval_api` to `"http://snekbox:8060/eval"` * `urls_snekbox_311_eval_api` to `"http://snekbox-311:8060/eval"`. + + Assuming you have Docker installed **and running**, enter the cloned repo in the command line and type `docker-compose up`. If working with snekbox you can run `docker-compose --profile 3.10 up` to also start up a 3.10 snekbox container, in addition to the default 3.11 container! @@ -241,9 +241,10 @@ Your bot is now running, but this method makes debugging with an IDE a fairly in The advantage of this method is that you can run the bot's code in your preferred editor, with debugger and all, while keeping all the setup of the bot's various dependencies inside Docker. * Append the following line to your `.env` file: `API_KEYS_SITE_API=badbot13m0n8f570f942013fc818f234916ca531`. -* In your `.env.server` file, set `urls_site` to `"localhost:8000"` and `urls_site_api` to `"localhost:8000/api"`. If you wish to keep using `web:8000` and `web:8000/api`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. +* In your `.env.server` file, set `urls_site_api` to `"http://localhost:8000/api"`. If you wish to keep using `http://web:8000/api`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. * To work with snekbox, set `urls_snekbox_eval_api` to `"http://localhost:8060/eval"` and `urls_snekbox_311_eval_api` to `"http://localhost:8065/eval"` + You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: * `docker-compose up web` to start the site container. This is required. @@ -328,6 +329,9 @@ We are always open to more statistics so add as many as you can! --- ### Optional: Working with the help forum + +**Note**: This is only required when you're not configuring the bot [automatically](#automatic-configuration) + If you will be working on a feature that includes the python help forum, you will need to use `Forum Channels`. Forum channels cannot be included in a template, which is why this needs to be done by hand for the time being. @@ -382,6 +386,12 @@ If you want to run the bot locally, you can run `docker-compose up metricity` in --- +### Optional: Working with bot moderation logs +To be able to view moderation-related logs published by the bot to site, you will need to set `urls_site_logs_view` to `http://localhost:8000/staff/bot/logs` in your `.env.server`. +This will work in both Docker and locally. + +--- + ### Issues? If you have any issues with setting up the bot, come discuss it with us on the [#dev-contrib](https://discord.gg/2h3qBv8Xaa) channel on our server. -- cgit v1.2.3 From 0aea52cc5f1e480424d0056c7825f914f7baaa71 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Sat, 1 Apr 2023 21:30:41 +0200 Subject: apply wookie's suggestion to setting variables apply wookie's suggestion to setting variables --- .../resources/guides/pydis-guides/contributing/bot.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 6f2c7e74..009354d2 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -169,7 +169,7 @@ If you wish to set all values in your `env.server` for your testing server, you Additionally: -* At this stage, set `redis_use_fakeredis` to `true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). +* At this stage, set `redis_use_fakeredis=true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). We understand this is tedious which is why we **heavily recommend** using the [automatic configuration setup](#automatic-configuration) @@ -241,8 +241,8 @@ Your bot is now running, but this method makes debugging with an IDE a fairly in The advantage of this method is that you can run the bot's code in your preferred editor, with debugger and all, while keeping all the setup of the bot's various dependencies inside Docker. * Append the following line to your `.env` file: `API_KEYS_SITE_API=badbot13m0n8f570f942013fc818f234916ca531`. -* In your `.env.server` file, set `urls_site_api` to `"http://localhost:8000/api"`. If you wish to keep using `http://web:8000/api`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. -* To work with snekbox, set `urls_snekbox_eval_api` to `"http://localhost:8060/eval"` and `urls_snekbox_311_eval_api` to `"http://localhost:8065/eval"` +* In your `.env.server` file, set `urls_site_api="http://localhost:8000/api"`. If you wish to keep using `http://web:8000/api`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. +* To work with snekbox, set `urls_snekbox_eval_api="http://localhost:8060/eval"` and `urls_snekbox_311_eval_api="http://localhost:8065/eval"` You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: @@ -351,12 +351,12 @@ Once the previous steps are done, all that is left is to: --- ### Optional: Working with Redis -In [Configure the Bot](#envserver) you were asked to set `redis_use_fakeredis` to `true`. If you do not need to work on features that rely on Redis, this is enough. Fakeredis will give the illusion that features relying on Redis are saving information properly, but restarting the bot or the specific cog will wipe that information. +In [Configure the Bot](#envserver) you were asked to set `redis_use_fakeredis=true`. If you do not need to work on features that rely on Redis, this is enough. Fakeredis will give the illusion that features relying on Redis are saving information properly, but restarting the bot or the specific cog will wipe that information. If you are working on a feature that relies on Redis, you will need to enable Redis to make sure persistency is achieved for the feature across restarts. The first step towards that is going to `.env.server` and setting `redis.use_fakeredis` to `false`. #### Starting Redis in Docker (Recommended) -If you're using the Docker image provided in the project's Docker Compose, open your `.env.server` file. If you're running the bot in Docker, set `redis_host` to `redis`, and if you're running it on the host set it to `localhost`. Set `redis_password` to `""`. +If you're using the Docker image provided in the project's Docker Compose, open your `.env.server` file. If you're running the bot in Docker, set `redis_host=redis`, and if you're running it on the host set it to `localhost`. Set `redis_password=""`. #### Starting Redis Using Other Methods You can run your own instance of Redis, but in that case you will need to correctly set `redis_host` and `redis_port` in your `.env.server` file and the `REDIS_PASSWORD` in the `.env` file. @@ -387,11 +387,16 @@ If you want to run the bot locally, you can run `docker-compose up metricity` in --- ### Optional: Working with bot moderation logs -To be able to view moderation-related logs published by the bot to site, you will need to set `urls_site_logs_view` to `http://localhost:8000/staff/bot/logs` in your `.env.server`. +To be able to view moderation-related logs published by the bot to site, you will need to set `urls_site_logs_view=http://localhost:8000/staff/bot/logs` in your `.env.server`. This will work in both Docker and locally. --- +### Optional: Changing your command prefix +If you would like a prefix other than the default `!`, set `BOT_PREFIX={{your_prefix}}` in `.env.server`. + +--- + ### Issues? If you have any issues with setting up the bot, come discuss it with us on the [#dev-contrib](https://discord.gg/2h3qBv8Xaa) channel on our server. -- cgit v1.2.3 From 493ab3c7381ba89f640b7b53d2d0d96bf8944dbf Mon Sep 17 00:00:00 2001 From: shtlrs Date: Sat, 1 Apr 2023 21:36:50 +0200 Subject: replace docker-compose with docker compose --- .../guides/pydis-guides/contributing/bot.md | 23 +++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 009354d2..02564c0c 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -16,6 +16,7 @@ The Python bot is tightly coupled with the Python Discord server, so to have a f It's possible to set the bot to use a single channel for all cogs, but that will cause extreme spam and will be difficult to work with. You can start your own server and set up channels as you see fit, but for your convenience we have a template for a development server you can use: [https://discord.new/zmHtscpYN9E3](https://discord.new/zmHtscpYN9E3). + Keep in mind that this is not an exact mirror of the Python server, but a reduced version for testing purposes. The channels there are mostly the ones needed by the bot. --- @@ -229,11 +230,11 @@ In your `.env.server` file: -Assuming you have Docker installed **and running**, enter the cloned repo in the command line and type `docker-compose up`. +Assuming you have Docker installed **and running**, enter the cloned repo in the command line and type `docker compose up`. -If working with snekbox you can run `docker-compose --profile 3.10 up` to also start up a 3.10 snekbox container, in addition to the default 3.11 container! +If working with snekbox you can run `docker compose --profile 3.10 up` to also start up a 3.10 snekbox container, in addition to the default 3.11 container! -After pulling the images and building the containers, your bot will start. Enter your server and type `!help` (or whatever prefix you chose instead of `!`). +After pulling the images and building the containers, your bot will start. Enter your server and type `!help` (or whatever prefix you [chose](#optional--changing-your-command-prefix) instead of `!`). Your bot is now running, but this method makes debugging with an IDE a fairly involved process. For additional running methods, continue reading the following sections. @@ -247,17 +248,17 @@ The advantage of this method is that you can run the bot's code in your preferre You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: -* `docker-compose up web` to start the site container. This is required. -* `docker-compose up snekbox` to start the snekbox container. You only need this if you're planning on working on the snekbox cog. -* `docker-compose up snekbox-311` to start the snekbox 3.11 container. You only need this if you're planning on working on the snekbox cog. -* `docker-compose up redis` to start the Redis container. You only need this if you're not using fakeredis. For more info refer to [Working with Redis](#optional-working-with-redis). +* `docker compose up web` to start the site container. This is required. +* `docker compose up snekbox` to start the snekbox container. You only need this if you're planning on working on the snekbox cog. +* `docker compose up snekbox-311` to start the snekbox 3.11 container. You only need this if you're planning on working on the snekbox cog. +* `docker compose up redis` to start the Redis container. You only need this if you're not using fakeredis. For more info refer to [Working with Redis](#optional-working-with-redis). -You can start several services together: `docker-compose up web snekbox redis`. +You can start several services together: `docker compose up web snekbox redis`. ##### Setting Up a Development Environment The bot's code is Python code like any other. To run it locally, you will need the right version of Python with the necessary packages installed: -1. Make sure you have [Python 3.10](https://www.python.org/downloads/) installed. It helps if it is your system's default Python version. +1. Make sure you have [Python 3.11](https://www.python.org/downloads/) installed. It helps if it is your system's default Python version. 2. [Install Poetry](https://github.com/python-poetry/poetry#installation). 3. [Install the dependencies](../installing-project-dependencies). @@ -380,9 +381,9 @@ guild_id = replace_with_your_guild_id ``` To properly replicate production behavior, set the `staff_role_id`, `staff_categories`, and `ignore_categories` fields as well. -Now, `docker-compose up` will also start Metricity. +Now, `docker compose up` will also start Metricity. -If you want to run the bot locally, you can run `docker-compose up metricity` instead. +If you want to run the bot locally, you can run `docker compose up metricity` instead. --- -- cgit v1.2.3 From 4c923fa1cd6f1f5144036317b116aac745b3c345 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Wed, 5 Apr 2023 03:10:05 +0300 Subject: Add maximum auto-timeout duration validation --- pydis_site/apps/api/serializers.py | 31 +++++++++++++++++++++++++------ pydis_site/apps/api/tests/test_filters.py | 2 ++ 2 files changed, 27 insertions(+), 6 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index a3779094..2186b02c 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -1,4 +1,5 @@ """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 @@ -210,6 +211,8 @@ CHANNEL_SCOPE_FIELDS = ( ) 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.""" @@ -236,17 +239,24 @@ class FilterSerializer(ModelSerializer): 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 get_field_value(data, 'infraction_duration') - ) - and get_field_value(data, 'infraction_type') == 'NONE' + (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')) @@ -328,8 +338,9 @@ class FilterListSerializer(ModelSerializer): 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 data.get('infraction_duration') + data.get('infraction_reason') or infraction_duration ) and not data.get('infraction_type'): raise ValidationError("Infraction type is required with infraction duration or reason") @@ -344,6 +355,14 @@ class FilterListSerializer(ModelSerializer): 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 diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py index 3d3be51e..ebc4a2cf 100644 --- a/pydis_site/apps/api/tests/test_filters.py +++ b/pydis_site/apps/api/tests/test_filters.py @@ -274,6 +274,7 @@ class FilterValidationTests(AuthenticatedAPITestCase): ({"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), @@ -313,6 +314,7 @@ class FilterValidationTests(AuthenticatedAPITestCase): 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), -- cgit v1.2.3 From b8ddedc31d54f46bb86a7e7d200c163ea8806ee0 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 6 Apr 2023 01:09:09 +0300 Subject: Make additional_settings non-null with dict default This makes sure that the value in the DB is always a valid JSON, ensuring the unique constraint will work properly. --- pydis_site/apps/api/migrations/0088_new_filter_schema.py | 4 ++-- pydis_site/apps/api/models/bot/filters.py | 2 +- pydis_site/apps/api/tests/test_filters.py | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0088_new_filter_schema.py b/pydis_site/apps/api/migrations/0088_new_filter_schema.py index 2e1d78c9..675fdcec 100644 --- a/pydis_site/apps/api/migrations/0088_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0088_new_filter_schema.py @@ -64,7 +64,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: updated_at=object_.updated_at, filter_list=list_, description=object_.comment, - additional_settings=None, + additional_settings={}, guild_pings=None, filter_dm=None, dm_pings=None, @@ -105,7 +105,7 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField(auto_now=True)), ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)), - ('additional_settings', models.JSONField(help_text='Additional settings which are specific to this filter.', 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)), diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index aadb39aa..71f8771f 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -132,7 +132,7 @@ class FilterBase(ModelTimestampMixin, ModelReprMixin, models.Model): help_text="Why this filter has been added.", null=True ) additional_settings = models.JSONField( - null=True, help_text="Additional settings which are specific to this filter." + help_text="Additional settings which are specific to this filter.", default=dict ) filter_list = models.ForeignKey( FilterList, models.CASCADE, related_name="filters", diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py index ebc4a2cf..5059d651 100644 --- a/pydis_site/apps/api/tests/test_filters.py +++ b/pydis_site/apps/api/tests/test_filters.py @@ -230,6 +230,7 @@ class GenericFilterTests(AuthenticatedAPITestCase): 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) @@ -237,7 +238,7 @@ class GenericFilterTests(AuthenticatedAPITestCase): for field in sequence.model._meta.get_fields(): with self.subTest(field=field): - if field.null or field.name in sequence.ignored_fields + ("id",): + if field.null or field.name in ignored_fields: continue test_data = data.copy() -- cgit v1.2.3 From 42b153a3e7676a9b93ef268cdee1924a3c362e8c Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 6 Apr 2023 10:35:36 +0200 Subject: Add README for the redirects app (#924) This is kept more minimal than the other apps, as it's mostly for backwards compatibility. --- pydis_site/apps/redirect/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 pydis_site/apps/redirect/README.md (limited to 'pydis_site/apps') 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)! -- cgit v1.2.3 From c91bbccd85f64333a720a594435f9c7d33d9889d Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 6 Apr 2023 19:38:20 +0300 Subject: Fix contents and descriptions being too long for their field type --- pydis_site/apps/api/migrations/0088_new_filter_schema.py | 4 ++-- pydis_site/apps/api/models/bot/filters.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0088_new_filter_schema.py b/pydis_site/apps/api/migrations/0088_new_filter_schema.py index 675fdcec..9bc40779 100644 --- a/pydis_site/apps/api/migrations/0088_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0088_new_filter_schema.py @@ -103,8 +103,8 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('content', models.CharField(help_text='The definition of this filter.', max_length=100)), - ('description', models.CharField(help_text='Why this filter has been added.', max_length=200, null=True)), + ('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)), diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 71f8771f..620031dc 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -126,9 +126,8 @@ class FilterList(ModelTimestampMixin, ModelReprMixin, models.Model): class FilterBase(ModelTimestampMixin, ModelReprMixin, models.Model): """One specific trigger of a list.""" - content = models.CharField(max_length=100, help_text="The definition of this filter.") - description = models.CharField( - max_length=200, + 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( -- cgit v1.2.3 From 4eedb972d4712082b9a2e381ea728a4fd59ed445 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Thu, 6 Apr 2023 23:59:53 +0200 Subject: remove redundant talk about .env --- .../guides/pydis-guides/contributing/bot.md | 42 +++++++++------------- 1 file changed, 17 insertions(+), 25 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 02564c0c..00d70c0e 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -40,7 +40,21 @@ If your bot fails to start with a `PrivilegedIntentsRequired` exception, this in You now have both the bot's code and a server to run it on. It's time for you to connect the two by changing the bot's configurations. This can be done either automatically or manually, and we'll be detailing the steps for both. -One thing to know is that the bot relies on precisely **two** configuration files to work +One thing to know is that the bot relies on precisely **two** configuration files to work. + + +#### .env +This file will mostly contain sensitive information such as your `BOT_TOKEN` and your `REDIS_PASSWORD`. +It will also contain configurations related to external services the bot might use such as `USE_METRICITY`, which are unrelated to your server, with the only exception being `GUILD_ID`. + +This file will **always** need to have `BOT_TOKEN` and `GUILD_ID` values set: + +```text +BOT_TOKEN=YourDiscordBotTokenHere +GUILD_ID=YourDiscordTestServerIdHere +``` +See [here](../creating-bot-account) for help with obtaining the bot token, and [here](../obtaining-discord-ids#guild-id) for help with obtaining the guild's ID. + #### .env.server All server configuration values are saved in this file, which needs to be at the root directory of the cloned code. @@ -50,9 +64,6 @@ It also contains configurations such as how long it takes for a help channel to This file will be created for you automatically if you decide to go with [automatic configuration](#automatic-configuration), otherwise a lot of it has to be done by hand which will be detailed in the [manual configuration](#manual-configuration) section. -#### .env -This file will mostly contain sensitive information such as your `BOT_TOKEN` and your `REDIS_PASSWORD`. -It will also contain configurations related to external services the bot might use such as `USE_METRICITY`, which are unrelated to your server, with the only exception being `GUILD_ID`. **Notes**: @@ -66,19 +77,6 @@ without having to spend much time copying IDs from your server into your configu **Note**: The script will also work on existing servers as long as the channel names are the same as the one in Python Discord. ##### 1. Script setup -##### 1.1. Environment variables -You will need to create a file called `.env`, which will contain two required values: `BOT_TOKEN` and `GUILD_ID`. - -Inside, add the following two lines: - -```text -BOT_TOKEN=YourDiscordBotTokenHere -GUILD_ID=YourDiscordTestServerIdHere -``` -See [here](../creating-bot-account) for help with obtaining the bot token, and [here](../obtaining-discord-ids#guild-id) for help with obtaining the guild's ID. - - -##### 1.2 Setting up the script environment The bootstrapping script is a Python program so you will need a compatible Python version and the necessary dependencies installed, which are all detailed here: @@ -86,7 +84,7 @@ which are all detailed here: 2. [Install Poetry](https://github.com/python-poetry/poetry#installation). 3. [Install the dependencies](../installing-project-dependencies). -#### 2. Running the script +##### 2. Running the script Once the script setup phase is complete, all that is left is to run it. To do this, you'll simply need to run the `configure` poetry task: @@ -105,12 +103,11 @@ This file will contain the extracted IDs from your server which are necessary fo **Note**: Skip this part if you used the automatic configuration. -##### .env.server Reading this means that you're ready for a bit of manual labour. If for some reason you've missed the automatic server setup section, you can read about it [here](#automatic-configuration) To configure the bot manually, you will **only** need to set the values for the channels, roles, categories, etc. -that are used by the component you are developing. +that are used by the component you are developing inside the `.env.server` file. For example, if we're testing a feature that only needs the `announcements` channel: @@ -191,11 +188,6 @@ We understand this is tedious which is why we **heavily recommend** using the [a
      -##### .env -The second file you need to create is the one containing the environment variables, and needs to be named `.env`. -Inside, add the line `BOT_TOKEN=YourDiscordBotTokenHere`. See [here](../creating-bot-account) for help with obtaining the bot token. - ---- ### Run it! -- cgit v1.2.3 From 42e7a8914be9cf8d4a10f17ebe4ace49c5046c25 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Fri, 7 Apr 2023 00:42:32 +0200 Subject: improve wording of a couple sections --- .../content/resources/guides/pydis-guides/contributing/bot.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 00d70c0e..769ea838 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -80,7 +80,7 @@ without having to spend much time copying IDs from your server into your configu The bootstrapping script is a Python program so you will need a compatible Python version and the necessary dependencies installed, which are all detailed here: -1. Make sure you have [Python 3.10](https://www.python.org/downloads/) installed. It helps if it is your system's default Python version. +1. Make sure you have [Python 3.11](https://www.python.org/downloads/) installed. It helps if it is your system's default Python version. 2. [Install Poetry](https://github.com/python-poetry/poetry#installation). 3. [Install the dependencies](../installing-project-dependencies). @@ -106,8 +106,8 @@ This file will contain the extracted IDs from your server which are necessary fo Reading this means that you're ready for a bit of manual labour. If for some reason you've missed the automatic server setup section, you can read about it [here](#automatic-configuration) -To configure the bot manually, you will **only** need to set the values for the channels, roles, categories, etc. -that are used by the component you are developing inside the `.env.server` file. +To configure the bot manually, you will **only** need to set inside the `.env.server` file the values for the channels, roles, categories, etc. +that are used by the component you are developing. For example, if we're testing a feature that only needs the `announcements` channel: @@ -165,9 +165,6 @@ If you wish to set all values in your `env.server` for your testing server, you * `webhooks_` * `emojis_` -Additionally: - -* At this stage, set `redis_use_fakeredis=true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). We understand this is tedious which is why we **heavily recommend** using the [automatic configuration setup](#automatic-configuration) @@ -220,6 +217,7 @@ In your `.env.server` file: * `urls_snekbox_eval_api` to `"http://snekbox:8060/eval"` * `urls_snekbox_311_eval_api` to `"http://snekbox-311:8060/eval"`. +* At this stage, set `redis_use_fakeredis` to `true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). Assuming you have Docker installed **and running**, enter the cloned repo in the command line and type `docker compose up`. -- cgit v1.2.3 From 0524176aa3392aa9978a420c6089012e91ebbbc3 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 14 Apr 2023 14:42:16 +0200 Subject: Add README to the resources app (#934) --- pydis_site/apps/resources/README.md | 29 ++++++ pydis_site/apps/resources/urls.py | 8 +- pydis_site/apps/resources/views.py | 126 +++++++++++++++++++++++++++ pydis_site/apps/resources/views/__init__.py | 3 - pydis_site/apps/resources/views/resources.py | 126 --------------------------- 5 files changed, 160 insertions(+), 132 deletions(-) create mode 100644 pydis_site/apps/resources/README.md create mode 100644 pydis_site/apps/resources/views.py delete mode 100644 pydis_site/apps/resources/views/__init__.py delete mode 100644 pydis_site/apps/resources/views/resources.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/README.md b/pydis_site/apps/resources/README.md new file mode 100644 index 00000000..6f41319a --- /dev/null +++ b/pydis_site/apps/resources/README.md @@ -0,0 +1,29 @@ +# The "resources" app + +This Django application powering the resources list [on our +website](https://www.pythondiscord.com/resources/). + +## Directory structure + +The main point of interest here lies in the `resources` directory: every +`.yaml` file in here represents a resource that is listed on our website. If +you are looking for the place to suggest new resources, said directory is the +place to create a new YAML file. In regards to the required keys and our +values, it's best to check the other files we have for a reference. + +The app has a single view in `views.py` that takes care of reading the `.yaml` +file. This is a standard Django view, mounted in `urls.py` as usual. + +Similar to the [home app](../home), the `templatetags` directory contains custom +[template tags and +filters](https://docs.djangoproject.com/en/dev/howto/custom-template-tags/) used +here. + +The `tests` directory validates that our redirects and helper functions work as +expected. If you made changes to the app and are looking for guidance on adding +new tests, the [Django tutorial introducing automated +testing](https://docs.djangoproject.com/en/dev/intro/tutorial05/) is a good +place to start. + +This application does not use the database and as such does not have models nor +migrations. diff --git a/pydis_site/apps/resources/urls.py b/pydis_site/apps/resources/urls.py index ed24dc99..cb33a9d7 100644 --- a/pydis_site/apps/resources/urls.py +++ b/pydis_site/apps/resources/urls.py @@ -1,9 +1,11 @@ from django_distill import distill_path -from pydis_site.apps.resources import views +from pydis_site.apps.resources.views import ResourceView app_name = "resources" urlpatterns = [ - distill_path("", views.resources.ResourceView.as_view(), name="index"), - distill_path("/", views.resources.ResourceView.as_view(), name="index"), + # Using `distill_path` instead of `path` allows this to be available + # in static preview builds. + distill_path("", ResourceView.as_view(), name="index"), + distill_path("/", ResourceView.as_view(), name="index"), ] diff --git a/pydis_site/apps/resources/views.py b/pydis_site/apps/resources/views.py new file mode 100644 index 00000000..2375f722 --- /dev/null +++ b/pydis_site/apps/resources/views.py @@ -0,0 +1,126 @@ +import json +import typing as t +from pathlib import Path + +import yaml +from django.core.handlers.wsgi import WSGIRequest +from django.http import HttpResponse, HttpResponseNotFound +from django.shortcuts import render +from django.views import View + +from pydis_site import settings +from pydis_site.apps.resources.templatetags.to_kebabcase import to_kebabcase + +RESOURCES_PATH = Path(settings.BASE_DIR, "pydis_site", "apps", "resources", "resources") + + +class ResourceView(View): + """Our curated list of good learning resources.""" + + @staticmethod + def _sort_key_disregard_the(tuple_: tuple) -> str: + """Sort a tuple by its key alphabetically, disregarding 'the' as a prefix.""" + name, resource = tuple_ + name = name.casefold() + if name.startswith("the ") or name.startswith("the_"): + return name[4:] + return name + + def __init__(self, *args, **kwargs): + """Set up all the resources.""" + super().__init__(*args, **kwargs) + + # Load the resources from the yaml files in /resources/ + self.resources = { + path.stem: yaml.safe_load(path.read_text()) + for path in RESOURCES_PATH.rglob("*.yaml") + } + + # Sort the resources alphabetically + self.resources = dict(sorted(self.resources.items(), key=self._sort_key_disregard_the)) + + # Parse out all current tags + resource_tags = { + "topics": set(), + "payment_tiers": set(), + "difficulty": set(), + "type": set(), + } + for resource_name, resource in self.resources.items(): + css_classes = [] + for tag_type in resource_tags.keys(): + # Store the tags into `resource_tags` + tags = resource.get("tags", {}).get(tag_type, []) + for tag in tags: + tag = tag.title() + tag = tag.replace("And", "and") + resource_tags[tag_type].add(tag) + + # Make a CSS class friendly representation too, while we're already iterating. + for tag in tags: + css_tag = to_kebabcase(f"{tag_type}-{tag}") + css_classes.append(css_tag) + + # Now add the css classes back to the resource, so we can use them in the template. + self.resources[resource_name]["css_classes"] = " ".join(css_classes) + + # Set up all the filter checkbox metadata + self.filters = { + "Difficulty": { + "filters": sorted(resource_tags.get("difficulty")), + "icon": "fas fa-brain", + "hidden": False, + }, + "Type": { + "filters": sorted(resource_tags.get("type")), + "icon": "fas fa-photo-video", + "hidden": False, + }, + "Payment tiers": { + "filters": sorted(resource_tags.get("payment_tiers")), + "icon": "fas fa-dollar-sign", + "hidden": True, + }, + "Topics": { + "filters": sorted(resource_tags.get("topics")), + "icon": "fas fa-lightbulb", + "hidden": True, + } + } + + # The bottom topic should always be "Other". + self.filters["Topics"]["filters"].remove("Other") + self.filters["Topics"]["filters"].append("Other") + + # A complete list of valid filter names + self.valid_filters = { + "topics": [to_kebabcase(topic) for topic in self.filters["Topics"]["filters"]], + "payment_tiers": [ + to_kebabcase(tier) for tier in self.filters["Payment tiers"]["filters"] + ], + "type": [to_kebabcase(type_) for type_ in self.filters["Type"]["filters"]], + "difficulty": [to_kebabcase(tier) for tier in self.filters["Difficulty"]["filters"]], + } + + def get(self, request: WSGIRequest, resource_type: t.Optional[str] = None) -> HttpResponse: + """List out all the resources, and any filtering options from the URL.""" + # Add type filtering if the request is made to somewhere like /resources/video. + # We also convert all spaces to dashes, so they'll correspond with the filters. + if resource_type: + dashless_resource_type = resource_type.replace("-", " ") + + if dashless_resource_type.title() not in self.filters["Type"]["filters"]: + return HttpResponseNotFound() + + resource_type = resource_type.replace(" ", "-") + + return render( + request, + template_name="resources/resources.html", + context={ + "resources": self.resources, + "filters": self.filters, + "valid_filters": json.dumps(self.valid_filters), + "resource_type": resource_type, + } + ) diff --git a/pydis_site/apps/resources/views/__init__.py b/pydis_site/apps/resources/views/__init__.py deleted file mode 100644 index 986f3e10..00000000 --- a/pydis_site/apps/resources/views/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .resources import ResourceView - -__all__ = ["ResourceView"] diff --git a/pydis_site/apps/resources/views/resources.py b/pydis_site/apps/resources/views/resources.py deleted file mode 100644 index 2375f722..00000000 --- a/pydis_site/apps/resources/views/resources.py +++ /dev/null @@ -1,126 +0,0 @@ -import json -import typing as t -from pathlib import Path - -import yaml -from django.core.handlers.wsgi import WSGIRequest -from django.http import HttpResponse, HttpResponseNotFound -from django.shortcuts import render -from django.views import View - -from pydis_site import settings -from pydis_site.apps.resources.templatetags.to_kebabcase import to_kebabcase - -RESOURCES_PATH = Path(settings.BASE_DIR, "pydis_site", "apps", "resources", "resources") - - -class ResourceView(View): - """Our curated list of good learning resources.""" - - @staticmethod - def _sort_key_disregard_the(tuple_: tuple) -> str: - """Sort a tuple by its key alphabetically, disregarding 'the' as a prefix.""" - name, resource = tuple_ - name = name.casefold() - if name.startswith("the ") or name.startswith("the_"): - return name[4:] - return name - - def __init__(self, *args, **kwargs): - """Set up all the resources.""" - super().__init__(*args, **kwargs) - - # Load the resources from the yaml files in /resources/ - self.resources = { - path.stem: yaml.safe_load(path.read_text()) - for path in RESOURCES_PATH.rglob("*.yaml") - } - - # Sort the resources alphabetically - self.resources = dict(sorted(self.resources.items(), key=self._sort_key_disregard_the)) - - # Parse out all current tags - resource_tags = { - "topics": set(), - "payment_tiers": set(), - "difficulty": set(), - "type": set(), - } - for resource_name, resource in self.resources.items(): - css_classes = [] - for tag_type in resource_tags.keys(): - # Store the tags into `resource_tags` - tags = resource.get("tags", {}).get(tag_type, []) - for tag in tags: - tag = tag.title() - tag = tag.replace("And", "and") - resource_tags[tag_type].add(tag) - - # Make a CSS class friendly representation too, while we're already iterating. - for tag in tags: - css_tag = to_kebabcase(f"{tag_type}-{tag}") - css_classes.append(css_tag) - - # Now add the css classes back to the resource, so we can use them in the template. - self.resources[resource_name]["css_classes"] = " ".join(css_classes) - - # Set up all the filter checkbox metadata - self.filters = { - "Difficulty": { - "filters": sorted(resource_tags.get("difficulty")), - "icon": "fas fa-brain", - "hidden": False, - }, - "Type": { - "filters": sorted(resource_tags.get("type")), - "icon": "fas fa-photo-video", - "hidden": False, - }, - "Payment tiers": { - "filters": sorted(resource_tags.get("payment_tiers")), - "icon": "fas fa-dollar-sign", - "hidden": True, - }, - "Topics": { - "filters": sorted(resource_tags.get("topics")), - "icon": "fas fa-lightbulb", - "hidden": True, - } - } - - # The bottom topic should always be "Other". - self.filters["Topics"]["filters"].remove("Other") - self.filters["Topics"]["filters"].append("Other") - - # A complete list of valid filter names - self.valid_filters = { - "topics": [to_kebabcase(topic) for topic in self.filters["Topics"]["filters"]], - "payment_tiers": [ - to_kebabcase(tier) for tier in self.filters["Payment tiers"]["filters"] - ], - "type": [to_kebabcase(type_) for type_ in self.filters["Type"]["filters"]], - "difficulty": [to_kebabcase(tier) for tier in self.filters["Difficulty"]["filters"]], - } - - def get(self, request: WSGIRequest, resource_type: t.Optional[str] = None) -> HttpResponse: - """List out all the resources, and any filtering options from the URL.""" - # Add type filtering if the request is made to somewhere like /resources/video. - # We also convert all spaces to dashes, so they'll correspond with the filters. - if resource_type: - dashless_resource_type = resource_type.replace("-", " ") - - if dashless_resource_type.title() not in self.filters["Type"]["filters"]: - return HttpResponseNotFound() - - resource_type = resource_type.replace(" ", "-") - - return render( - request, - template_name="resources/resources.html", - context={ - "resources": self.resources, - "filters": self.filters, - "valid_filters": json.dumps(self.valid_filters), - "resource_type": resource_type, - } - ) -- cgit v1.2.3 From 96272cab8f52ce8f4fbc4506e1de5efb5e81ac01 Mon Sep 17 00:00:00 2001 From: etrotta Date: Sat, 15 Apr 2023 18:46:51 -0300 Subject: Add Python Graph Gallery to the Resources --- .../apps/resources/resources/python_graph_gallery.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 pydis_site/apps/resources/resources/python_graph_gallery.yaml (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/python_graph_gallery.yaml b/pydis_site/apps/resources/resources/python_graph_gallery.yaml new file mode 100644 index 00000000..b8aaeb4d --- /dev/null +++ b/pydis_site/apps/resources/resources/python_graph_gallery.yaml @@ -0,0 +1,17 @@ +description: A collection of hundreds of charts made with Python with their associated reproducible code. +name: Python Graph Gallery +title_url: https://www.python-graph-gallery.com/ +urls: + - icon: branding/github + url: https://github.com/holtzy/The-Python-Graph-Gallery + color: black +tags: + topics: + - data science + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tutorial -- cgit v1.2.3 From 762763cf00651526b798e70995eaf320d4275e97 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 16 Apr 2023 01:00:32 +0200 Subject: Add a README to the staff app Plus minor refactorings --- pydis_site/apps/staff/README.md | 19 +++++++++++++++++++ .../apps/staff/templatetags/deletedmessage_filters.py | 2 +- pydis_site/apps/staff/urls.py | 2 +- pydis_site/apps/staff/views.py | 11 +++++++++++ pydis_site/apps/staff/viewsets/__init__.py | 3 --- pydis_site/apps/staff/viewsets/logs.py | 11 ----------- 6 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 pydis_site/apps/staff/README.md create mode 100644 pydis_site/apps/staff/views.py delete mode 100644 pydis_site/apps/staff/viewsets/__init__.py delete mode 100644 pydis_site/apps/staff/viewsets/logs.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/staff/README.md b/pydis_site/apps/staff/README.md new file mode 100644 index 00000000..db263e5e --- /dev/null +++ b/pydis_site/apps/staff/README.md @@ -0,0 +1,19 @@ +# The "staff" app + +This Django application hosts any staff-internal tooling, which, at time of +writing, only is an endpoint to view logs uploaded by the Python bot. + +This app mainly interacts with a single model from the `api` app, and has no +models on its own. The following files and directories are of interest: + +- [`templatetags`](./templatetags) contains custom template tags that help with + formatting the HTML templates of this app (these can be found in the template + root direcetory). + +- [`tests`](./tests) contains standard Django unit tests that validate both the + template tags and functionality of the log viewer itself. + +- [`urls.py`](./urls.py) contains the regular Django URL routing logic. + +- [`views.py`](./views.py) contains standard Django views. In our case, the + main work happens in the template, so this is relatively straightforward. diff --git a/pydis_site/apps/staff/templatetags/deletedmessage_filters.py b/pydis_site/apps/staff/templatetags/deletedmessage_filters.py index 5026068e..9d8f1819 100644 --- a/pydis_site/apps/staff/templatetags/deletedmessage_filters.py +++ b/pydis_site/apps/staff/templatetags/deletedmessage_filters.py @@ -28,5 +28,5 @@ def footer_datetime(timestamp: str) -> datetime: @register.filter def visible_newlines(text: str) -> str: - """Takes an embed timestamp and returns a timezone-aware datetime object.""" + """Visualizes newlines in text by replacing them with a grey-ish `↵`.""" return text.replace("\n", "
      ") diff --git a/pydis_site/apps/staff/urls.py b/pydis_site/apps/staff/urls.py index ca8d1a0f..0565592b 100644 --- a/pydis_site/apps/staff/urls.py +++ b/pydis_site/apps/staff/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .viewsets import LogView +from .views import LogView app_name = 'staff' urlpatterns = [ diff --git a/pydis_site/apps/staff/views.py b/pydis_site/apps/staff/views.py new file mode 100644 index 00000000..22dede95 --- /dev/null +++ b/pydis_site/apps/staff/views.py @@ -0,0 +1,11 @@ +from django.views.generic.detail import DetailView + +from pydis_site.apps.api.models.bot.message_deletion_context import MessageDeletionContext + + +class LogView(DetailView): + """The default view for the Deleted Messages logs.""" + + model = MessageDeletionContext + context_object_name = "deletion_context" + template_name = "staff/logs.html" diff --git a/pydis_site/apps/staff/viewsets/__init__.py b/pydis_site/apps/staff/viewsets/__init__.py deleted file mode 100644 index 6b10eb83..00000000 --- a/pydis_site/apps/staff/viewsets/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .logs import LogView - -__all__ = ["LogView"] diff --git a/pydis_site/apps/staff/viewsets/logs.py b/pydis_site/apps/staff/viewsets/logs.py deleted file mode 100644 index 22dede95..00000000 --- a/pydis_site/apps/staff/viewsets/logs.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.views.generic.detail import DetailView - -from pydis_site.apps.api.models.bot.message_deletion_context import MessageDeletionContext - - -class LogView(DetailView): - """The default view for the Deleted Messages logs.""" - - model = MessageDeletionContext - context_object_name = "deletion_context" - template_name = "staff/logs.html" -- cgit v1.2.3 From 6053f4fd61b31adebd251e051ce03c46c73c0e6d Mon Sep 17 00:00:00 2001 From: Steele Farnsworth Date: Sun, 16 Apr 2023 15:37:32 -0400 Subject: Update rule 5 (#942) "may break laws, breach terms of services, or are malicious or inappropriate." -> "may violate terms of service, or that may be deemed inappropriate, malicious, or illegal." --- pydis_site/apps/content/resources/rules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/rules.md b/pydis_site/apps/content/resources/rules.md index 803c8041..a0bdb38d 100644 --- a/pydis_site/apps/content/resources/rules.md +++ b/pydis_site/apps/content/resources/rules.md @@ -9,7 +9,7 @@ We have a small but strict set of rules on our server. Please read over them and > 2. Follow the [Discord Community Guidelines](https://discordapp.com/guidelines) and [Terms of Service](https://discordapp.com/terms). > 3. Respect staff members and listen to their instructions. > 4. Use English to the best of your ability. Be polite if someone speaks English imperfectly. -> 5. Do not provide or request help on projects that may break laws, breach terms of services, or are malicious or inappropriate. +> 5. Do not provide or request help on projects that may violate terms of service, or that may be deemed inappropriate, malicious, or illegal. > 6. Do not post unapproved advertising. > 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. -- cgit v1.2.3 From 6dcdcf519b8a178b4884797d85ad6b2e1b9582c3 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 16 Apr 2023 22:48:08 +0100 Subject: update rule 5 (#943) --- pydis_site/apps/api/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 20431a61..54fbf809 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -149,9 +149,9 @@ class RulesView(APIView): ["english", "language"] ), ( - "Do not provide or request help on projects that may break laws, " - "breach terms of services, or are malicious or inappropriate.", - ["infraction", "tos", "breach", "malicious", "inappropriate"] + "Do not provide or request help on projects that may violate terms of service, " + "or that may be deemed inappropriate, malicious, or illegal.", + ["infraction", "tos", "breach", "malicious", "inappropriate", "illegal"] ), ( "Do not post unapproved advertising.", -- cgit v1.2.3 From ee04b387f47bd268894b8d0ef3920b4f9c4116e1 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 21 Apr 2023 00:29:51 +0200 Subject: Implement grammar fix Co-authored-by: wookie184 --- pydis_site/apps/staff/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/staff/README.md b/pydis_site/apps/staff/README.md index db263e5e..6707bf26 100644 --- a/pydis_site/apps/staff/README.md +++ b/pydis_site/apps/staff/README.md @@ -1,7 +1,7 @@ # The "staff" app This Django application hosts any staff-internal tooling, which, at time of -writing, only is an endpoint to view logs uploaded by the Python bot. +writing, is only an endpoint to view logs uploaded by the Python bot. This app mainly interacts with a single model from the `api` app, and has no models on its own. The following files and directories are of interest: -- cgit v1.2.3 From cbc67702bb58a3a2d3c80521f21e1fd6f7f203e9 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 21 Apr 2023 22:21:13 +0200 Subject: Crosscheck rules between API and static rules --- pydis_site/apps/api/tests/test_rules.py | 38 ++++++++++++++++++++++++++++++ pydis_site/apps/api/views.py | 2 +- pydis_site/apps/content/resources/rules.md | 2 +- 3 files changed, 40 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_rules.py b/pydis_site/apps/api/tests/test_rules.py index d08c5fae..3ee2d4e0 100644 --- a/pydis_site/apps/api/tests/test_rules.py +++ b/pydis_site/apps/api/tests/test_rules.py @@ -1,3 +1,7 @@ +import itertools +import re +from pathlib import Path + from django.urls import reverse from .base import AuthenticatedAPITestCase @@ -33,3 +37,37 @@ class RuleAPITests(AuthenticatedAPITestCase): url = reverse('api:rules') response = self.client.get(url + '?link_format=unknown') self.assertEqual(response.status_code, 400) + + +class RuleCorrectnessTests(AuthenticatedAPITestCase): + """Verifies that the rules from the API and by the static rules in the content app match.""" + + @classmethod + def setUpTestData(cls): + cls.markdown_rule_re = re.compile(r'^> \d+\. (.*)$') + + def test_rules_in_markdown_file_roughly_equal_api_rules(self) -> None: + url = reverse('api:rules') + api_response = self.client.get(url + '?link_format=md') + api_rules = tuple(rule for (rule, _tags) in api_response.json()) + + markdown_rules_path = ( + Path(__file__).parent.parent.parent / 'content' / 'resources' / 'rules.md' + ) + + markdown_rules = [] + for line in markdown_rules_path.read_text().splitlines(): + matches = self.markdown_rule_re.match(line) + if matches is not None: + markdown_rules.append(matches.group(1)) + + zipper = itertools.zip_longest(api_rules, markdown_rules) + for idx, (api_rule, markdown_rule) in enumerate(zipper): + with self.subTest(f"Rule {idx}"): + self.assertIsNotNone( + markdown_rule, f"The API has more rules than {markdown_rules_path}" + ) + self.assertIsNotNone( + api_rule, f"{markdown_rules_path} has more rules than the API endpoint" + ) + self.assertEqual(markdown_rule, api_rule) diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 54fbf809..b1b7dc0f 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -120,7 +120,7 @@ class RulesView(APIView): link_format ) discord_tos = self._format_link( - 'Terms Of Service', + 'Terms of Service', 'https://discordapp.com/terms', link_format ) diff --git a/pydis_site/apps/content/resources/rules.md b/pydis_site/apps/content/resources/rules.md index a0bdb38d..e55c6715 100644 --- a/pydis_site/apps/content/resources/rules.md +++ b/pydis_site/apps/content/resources/rules.md @@ -5,7 +5,7 @@ icon: fab fa-discord --- We have a small but strict set of rules on our server. Please read over them and take them on board. If you don't understand a rule or need to report an incident, please send a direct message to @ModMail! -> 1. Follow the [Python Discord Code of Conduct](/pages/code-of-conduct/). +> 1. Follow the [Python Discord Code of Conduct](https://pythondiscord.com/pages/code-of-conduct/). > 2. Follow the [Discord Community Guidelines](https://discordapp.com/guidelines) and [Terms of Service](https://discordapp.com/terms). > 3. Respect staff members and listen to their instructions. > 4. Use English to the best of your ability. Be polite if someone speaks English imperfectly. -- cgit v1.2.3 From facb030041d916713c9fdacf18c57420d43bc433 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 14 Apr 2023 21:50:34 +0100 Subject: Seperate manual configuration out of the main bot contributing guide Also update the guide to be far simpler, and less words to read for our contributors to get setup. --- .../bot-extended-configuration-options.md | 192 +++++++++++ .../guides/pydis-guides/contributing/bot.md | 353 ++++----------------- 2 files changed, 257 insertions(+), 288 deletions(-) create mode 100644 pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md new file mode 100644 index 00000000..b031a668 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md @@ -0,0 +1,192 @@ +--- +title: Extended options for configuring Bot +description: A guide that lists all extended/optional options to configure the bot further. +icon: fab fa-github +toc: 3 +--- + +## Manual constants configuration + +Reading this means that you're ready for a bit of manual labour. +If for some reason you've missed the automatic server setup section, you can read about it in the bot contributing guide [here](../bot.md#envserver) + +To configure the bot manually, you will **only** need to set inside the `.env.server` file the values for the channels, roles, categories, etc. +that are used by the component you are developing. + +For example, if we're testing a feature that only needs the `announcements` channel: + +`constants.py` + +```py + +class EnvConfig: + # Defines from where & how Pydantic will be looking for env variables + ... + +class _Channels(EnvConfig): + + EnvConfig.Config.env_prefix = "channels_" + + announcements = 1079790565794779156 + changelog = 1077877318564991006 + +# Instantiate the class & load the configuration +Channels = _Channels() +``` + +`.env.server` file: + +```text +# .env.server + +channels_announcements=1077875228002234398 +``` + +When you launch your bot, `pydantic` will load up the server constants from the `.env.server` file if they exist. + +Each constants class will define its own prefix, which will make `pydantic` look for variables that will look like `{{env_prefix}}{{attribute_name}}` in the environment files + +In our example, this will imply that pydantic will look for both `channels_announcements` and `channels_changelog` in the `.env.server` file. + +As you can see here, only `channels_announcements` has been defined in the `.env.server` file since it's the only one needed, which will tell `pydantic` +to use the value **1077875228002234398** for the `announcements` attribute instead of the default **1079790565794779156**, and use the default value for the `changelog` attribute + +```python +>>> Channels.announcements +1077875228002234398 +>>> Channels.changelong +1077877318564991006 +``` + +See [here](../obtaining-discord-ids) for help with obtaining Discord IDs. + +If you wish to set all values in your `env.server` for your testing server, you need to set **all** the ones prefixed with: + +* `guild_` +* `categories_` +* `channels_` +* `roles_` +* `webhooks_` +* `emojis_` + +## Working with the help forum + +**Note**: This is only required when you're not configuring the bot [automatically](#automatic-configuration) + +If you will be working on a feature that includes the python help forum, you will need to use `Forum Channels`. + +Forum channels cannot be included in a template, which is why this needs to be done by hand for the time being. + +To activate forum channels, your Discord server needs to have the community feature. +If that's not the case already, here are the steps required to do it: + +1. Go to server settings +2. Scroll down to the `COMMUNITY` section and click on `Enable Community` +3. Click on `Get Started` and fill out the necessary info + +Once the previous steps are done, all that is left is to: + +1. Create a new channel +2. Choose the `Forum` type +3. [Copy its ID](../obtaining-discord-ids#channel-id) +4. Add the following line to the `.env.server` file: `channels_python_help={newly_created_forum_channel_id}` + +--- + +We understand this is tedious which is why we **heavily recommend** using the [automatic configuration setup](../bot.md#automatic-configuration) + +--- + +## Reloading parts of the bot +If you make changes to an extension, you might not need to restart the entire bot for the changes to take effect. The command `!ext reload ` re-imports the files associated with the extension. +Invoke `!ext list` for a full list of the available extensions. In this bot in particular, cogs are defined inside extensions. + +Note that if you changed code that is not associated with a particular extension, such as utilities, converters, and constants, you will need to restart the bot. + +## Adding new statistics + +Details on how to add new statistics can be found on the [statistic infrastructure page](https://blog.pythondiscord.com/statistics-infrastructure). +We are always open to more statistics so add as many as you can! + +--- + +## With the Bot Running Locally +The advantage of this method is that you can run the bot's code in your preferred editor, with debugger and all, while keeping all the setup of the bot's various dependencies inside Docker. + +* Append the following line to your `.env` file: `API_KEYS_SITE_API=badbot13m0n8f570f942013fc818f234916ca531`. +* In your `.env.server` file, set `urls_site_api="http://localhost:8000/api"`. If you wish to keep using `http://web:8000/api`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. +* To work with snekbox, set `urls_snekbox_eval_api="http://localhost:8060/eval"` and `urls_snekbox_311_eval_api="http://localhost:8065/eval"` + + +You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: + +* `docker compose up web` to start the site container. This is required. +* `docker compose up snekbox` to start the snekbox container. You only need this if you're planning on working on the snekbox cog. +* `docker compose up snekbox-311` to start the snekbox 3.11 container. You only need this if you're planning on working on the snekbox cog. +* `docker compose up redis` to start the Redis container. You only need this if you're not using fakeredis. For more info refer to [Working with Redis](#optional-working-with-redis). + +You can start several services together: `docker compose up web snekbox redis`. + +### Setting Up a Development Environment + +With at least the site running in Docker already (see the previous section on how to start services separately), you can now start the bot locally through the command line, or through your preferred IDE. +
      + + +
      +
      + +### With More Things Running Locally +You can run additional services on the host, but this guide won't go over how to install and start them in this way. +If possible, prefer to start the services through Docker to replicate the production environment as much as possible. + +The site, however, is a mandatory service for the bot. +Refer to the [previous section](#with-the-bot-running-locally) and the [site contributing guide](../site) to learn how to start it on the host, in which case you will need to change `urls.site` in `.env.server` to wherever the site is being hosted. + +--- + +### Starting Redis Using Other Methods +You can run your own instance of Redis, but in that case you will need to correctly set `redis_host` and `redis_port` in your `.env.server` file and the `REDIS_PASSWORD` in the `.env` file. +**Note**: The previously mentioned variables **SHOULD NOT** be overriden or changed in `constants.py` + +--- + +## Working with Metricity +[Metricity](https://github.com/python-discord/metricity) is our home-grown bot for collecting metrics on activity within the server, such as what users are present, and IDs of the messages they've sent. +Certain features in the Python bot rely on querying the Metricity database for information such as the number of messages a user has sent, most notably the voice verification system. + +If you wish to work on a feature that relies on Metricity, for your convenience we've made the process of using it relatively painless with Docker: Enter the `.env` file you've written for the Python bot, and append the line `USE_METRICITY=true`. +Note that if you don't need Metricity, there's no reason to have it enabled as it is just unnecessary overhead. + +To make the Metricity bot work with your test server, you will need to override its configurations similarly to the Python bot. +You can see the various configurations in [the Metricity repo](https://github.com/python-discord/metricity), but the bare minimum is the guild ID setting. +In your local version of the Python bot repo, create a file called `metricity-config.toml` and insert the following lines: +```yaml +[bot] +guild_id = replace_with_your_guild_id +``` +To properly replicate production behavior, set the `staff_role_id`, `staff_categories`, and `ignore_categories` fields as well. + +Now, `docker compose up` will also start Metricity. + +If you want to run the bot locally, you can run `docker compose up metricity` instead. + +--- + +## Working with bot moderation logs +To be able to view moderation-related logs published by the bot to site, you will need to set `urls_site_logs_view=http://localhost:8000/staff/bot/logs` in your `.env.server`. +This will work in both Docker and locally. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 769ea838..6639d92d 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -10,6 +10,35 @@ You should have already forked the repository and cloned it to your local machin This page will focus on the quickest steps one can take, with mentions of alternatives afterwards. --- +## Setting up the project + +### Setup Project Dependencies +Below are the dependencies you **must** have installed to get started with the bot. + +1. Make sure you have [Python 3.11](https://www.python.org/downloads/) installed. It helps if it is your system's default Python version. +1. [Install Poetry](https://github.com/python-poetry/poetry#installation). +1. [Install the project's dependencies](../installing-project-dependencies). +1. Docker. + +
      + + +
      +
      ### Set Up a Test Server The Python bot is tightly coupled with the Python Discord server, so to have a functional version of the bot you need a server with channels it can use. @@ -37,137 +66,45 @@ If your bot fails to start with a `PrivilegedIntentsRequired` exception, this in --- ### Configure the Bot -You now have both the bot's code and a server to run it on. It's time for you to connect the two by changing the bot's configurations. -This can be done either automatically or manually, and we'll be detailing the steps for both. - -One thing to know is that the bot relies on precisely **two** configuration files to work. +You now have both the bot's code and a server to run it on. It's time for you to connect the two by setting the bot's configuration. +Both `.env` and `.env.server` files we talk about below are ignored by git, so they do not get accidentally commit to the repository. #### .env -This file will mostly contain sensitive information such as your `BOT_TOKEN` and your `REDIS_PASSWORD`. -It will also contain configurations related to external services the bot might use such as `USE_METRICITY`, which are unrelated to your server, with the only exception being `GUILD_ID`. +This file will contain sensitive information such as your bot's token, do not share it with anybody! -This file will **always** need to have `BOT_TOKEN` and `GUILD_ID` values set: +To start, create a `.env` file in the project root with the below content. ```text BOT_TOKEN=YourDiscordBotTokenHere GUILD_ID=YourDiscordTestServerIdHere +BOT_PREFIX=YourDesiredPrefixHere ``` See [here](../creating-bot-account) for help with obtaining the bot token, and [here](../obtaining-discord-ids#guild-id) for help with obtaining the guild's ID. +Other values will be added to your `.env` over time as you need to interact with other parts of the bot, but those are not needed for a basic setup. For a full list of support values see the ENV file option [appendix](#appendix-full-env-file-options) #### .env.server -All server configuration values are saved in this file, which needs to be at the root directory of the cloned code. -This file contains the various configurations we use to make the bot run on the Python Discord server, such as channel and role IDs, and the emojis it works with. -It also contains configurations such as how long it takes for a help channel to time out, and how many messages a user needs to voice-verify. - -This file will be created for you automatically if you decide to go with [automatic configuration](#automatic-configuration), -otherwise a lot of it has to be done by hand which will be detailed in the [manual configuration](#manual-configuration) section. - - -**Notes**: - -* Both `.env` and `.env.server` are and should remain ignored by git, otherwise you risk pushing sensitive information. -* Skip the following step if you would like to configure the bot manually, but that will require more work. - -#### Automatic configuration -To make setup much easier, the script in `botstrap.py` bootstraps the configuration for you and helps you get started immediately, -without having to spend much time copying IDs from your server into your configuration file. - -**Note**: The script will also work on existing servers as long as the channel names are the same as the one in Python Discord. +All server related configuration values are saved in this file, which also needs to be at the root directory of the project. -##### 1. Script setup -The bootstrapping script is a Python program so you will need a compatible Python version and the necessary dependencies installed, -which are all detailed here: +We provide a script to automatically generate a server config. +**Note**: The script **only** works with servers created with the template mentioned above. -1. Make sure you have [Python 3.11](https://www.python.org/downloads/) installed. It helps if it is your system's default Python version. -2. [Install Poetry](https://github.com/python-poetry/poetry#installation). -3. [Install the dependencies](../installing-project-dependencies). - -##### 2. Running the script +If you want to setup the bot from an existing guild read out [manual configuration guide](../bot-extended-configuration-options#manual-constants-configuration). This is far more complicated and time consuming. -Once the script setup phase is complete, all that is left is to run it. -To do this, you'll simply need to run the `configure` poetry task: +Running the below command will use the `BOT_TOKEN` and `GUILD_ID` from the `.env` file you created above to download all of the relevant IDs from the template guild into your `.env.server` +**Note**: This script will overwrite the `.env.server` file. We suggest you put any configuration not generated by this script in to `.env` instead ```shell $ poetry run task configure ``` -Once the script has finished running, you'll notice the creation of a new file called [`.env.server`](#envserver) at your project's root directory. +Once the script has finished running, you'll notice the creation of a new file called `.env.server` at your project's root directory. This file will contain the extracted IDs from your server which are necessary for your bot to run. **Congratulations**, you have finished the configuration and can now [run your bot](#run-it). -#### Manual configuration - -**Note**: Skip this part if you used the automatic configuration. - -Reading this means that you're ready for a bit of manual labour. -If for some reason you've missed the automatic server setup section, you can read about it [here](#automatic-configuration) - -To configure the bot manually, you will **only** need to set inside the `.env.server` file the values for the channels, roles, categories, etc. -that are used by the component you are developing. - -For example, if we're testing a feature that only needs the `announcements` channel: - -`constants.py` - -```py - -class EnvConfig: - # Defines from where & how Pydantic will be looking for env variables - ... - -class _Channels(EnvConfig): - - EnvConfig.Config.env_prefix = "channels_" - - announcements = 1079790565794779156 - changelog = 1077877318564991006 - -# Instantiate the class & load the configuration -Channels = _Channels() -``` - -`.env.server` file: - -```text -# .env.server - -channels_announcements=1077875228002234398 -``` - -When you launch your bot, `pydantic` will load up the server constants from the `.env.server` file if they exist. - -Each constants class will define its own prefix, which will make `pydantic` look for variables that will look like `{{env_prefix}}{{attribute_name}}` in the environment files - -In our example, this will imply that pydantic will look for both `channels_announcements` and `channels_changelog` in the `.env.server` file. - -As you can see here, only `channels_announcements` has been defined in the `.env.server` file since it's the only one needed, which will tell `pydantic` -to use the value **1077875228002234398** for the `announcements` attribute instead of the default **1079790565794779156**, and use the default value for the `changelog` attribute - -```python ->>> Channels.announcements -1077875228002234398 ->>> Channels.changelong -1077877318564991006 -``` - -See [here](../obtaining-discord-ids) for help with obtaining Discord IDs. - -If you wish to set all values in your `env.server` for your testing server, you need to set **all** the ones prefixed with: - -* `guild_` -* `categories_` -* `channels_` -* `roles_` -* `webhooks_` -* `emojis_` - - -We understand this is tedious which is why we **heavily recommend** using the [automatic configuration setup](#automatic-configuration) -
      - -
      -
      - -In your `.env.server` file: - -* If you wish to work with snekbox, set the following: - * `urls_snekbox_eval_api` to `"http://snekbox:8060/eval"` - * `urls_snekbox_311_eval_api` to `"http://snekbox-311:8060/eval"`. - -* At this stage, set `redis_use_fakeredis` to `true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). - - -Assuming you have Docker installed **and running**, enter the cloned repo in the command line and type `docker compose up`. - -If working with snekbox you can run `docker compose --profile 3.10 up` to also start up a 3.10 snekbox container, in addition to the default 3.11 container! - -After pulling the images and building the containers, your bot will start. Enter your server and type `!help` (or whatever prefix you [chose](#optional--changing-your-command-prefix) instead of `!`). - -Your bot is now running, but this method makes debugging with an IDE a fairly involved process. For additional running methods, continue reading the following sections. -#### With the Bot Running Locally -The advantage of this method is that you can run the bot's code in your preferred editor, with debugger and all, while keeping all the setup of the bot's various dependencies inside Docker. +With all of the above setup, you can run The projec with `docker compose up`. This will start the bot an all required services! Enter your server and type `!help` (or whatever prefix you chose instead of `!`) to see the bot in action! -* Append the following line to your `.env` file: `API_KEYS_SITE_API=badbot13m0n8f570f942013fc818f234916ca531`. -* In your `.env.server` file, set `urls_site_api="http://localhost:8000/api"`. If you wish to keep using `http://web:8000/api`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. -* To work with snekbox, set `urls_snekbox_eval_api="http://localhost:8060/eval"` and `urls_snekbox_311_eval_api="http://localhost:8065/eval"` +Some other useful docker commands are as follows: +1. `docker compose pull` this pulls updates for all non-bot services, such as psotgres, redis and our [site](../site) project! +1. `docker compose build` this rebuilds the bot's docker image, this is only needed if you need to make changes to the bot's dependencies, or the Dockerfile itself. +1. `docker compose --profile 3.10 up` this starts a 3.10 snekbox container, in addition to the default 3.11 container! -You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: - -* `docker compose up web` to start the site container. This is required. -* `docker compose up snekbox` to start the snekbox container. You only need this if you're planning on working on the snekbox cog. -* `docker compose up snekbox-311` to start the snekbox 3.11 container. You only need this if you're planning on working on the snekbox cog. -* `docker compose up redis` to start the Redis container. You only need this if you're not using fakeredis. For more info refer to [Working with Redis](#optional-working-with-redis). - -You can start several services together: `docker compose up web snekbox redis`. - -##### Setting Up a Development Environment -The bot's code is Python code like any other. To run it locally, you will need the right version of Python with the necessary packages installed: - -1. Make sure you have [Python 3.11](https://www.python.org/downloads/) installed. It helps if it is your system's default Python version. -2. [Install Poetry](https://github.com/python-poetry/poetry#installation). -3. [Install the dependencies](../installing-project-dependencies). - -With at least the site running in Docker already (see the previous section on how to start services separately), you can now start the bot locally through the command line, or through your preferred IDE. -
      - - -
      -
      - -#### With More Things Running Locally -You can run additional services on the host, but this guide won't go over how to install and start them in this way. -If possible, prefer to start the services through Docker to replicate the production environment as much as possible. +Your bot is now running, all inside Docker. -The site, however, is a mandatory service for the bot. -Refer to the [previous section](#with-the-bot-running-locally) and the [site contributing guide](../site) to learn how to start it on the host, in which case you will need to change `urls.site` in `.env.server` to wherever the site is being hosted. +**Note**: If you want to read about how to make debugging with an IDE a easier, or for additional running methods, check out our [extended configuration guide](../bot-extended-configuration-options). --- -### Development Tips + +## Development Tips Now that you have everything setup, it is finally time to make changes to the bot! -#### Working with Git +### Working with Git Version control of our projects is done using Git and Github. It can be intimidating at first, so feel free to ask for any help in the server. [**Click here to see the basic Git workflow when contributing to one of our projects.**](../working-with-git/) -#### Running tests +### Running tests [This section](https://github.com/python-discord/bot/blob/main/tests/README.md#tools) of the README in the `tests` repository will explain how to run tests. The whole document explains how unittesting works, and how it fits in the context of our project. Make sure to run tests *before* pushing code. -Even if you run the bot through Docker, you might want to [setup a development environment](#setting-up-a-development-environment) in order to run the tests locally. +Even if you run the bot through Docker, you might want to [setup a development environment](../bot-extended-configuration-options#setting-up-a-development-environment) in order to run the tests locally. -#### Lint before you push +### Lint before you push As mentioned in the [contributing guidelines](../contributing-guidelines), you should make sure your code passes linting for each commit you make. For ease of development, you can install the pre-commit hook with `poetry run task precommit`, which will check your code every time you try to commit it. -For that purpose, even if you run the bot through Docker, you might want to [setup a development environment](#setting-up-a-development-environment), as otherwise the hook installation will fail. - -#### Reloading parts of the bot -If you make changes to an extension, you might not need to restart the entire bot for the changes to take effect. The command `!ext reload ` re-imports the files associated with the extension. -Invoke `!ext list` for a full list of the available extensions. In this bot in particular, cogs are defined inside extensions. - -Note that if you changed code that is not associated with a particular extension, such as utilities, converters, and constants, you will need to restart the bot. - -#### Adding new statistics - -Details on how to add new statistics can be found on the [statistic infrastructure page](https://blog.pythondiscord.com/statistics-infrastructure). -We are always open to more statistics so add as many as you can! - ---- - -### Optional: Working with the help forum - -**Note**: This is only required when you're not configuring the bot [automatically](#automatic-configuration) +For that purpose, even if you run the bot through Docker, you might want to [setup a development environment](../bot-extended-configuration-options#setting-up-a-development-environment), as otherwise the hook installation will fail. -If you will be working on a feature that includes the python help forum, you will need to use `Forum Channels`. - -Forum channels cannot be included in a template, which is why this needs to be done by hand for the time being. - -To activate forum channels, your Discord server needs to have the community feature. -If that's not the case already, here are the steps required to do it: -1. Go to server settings -2. Scroll down to the `COMMUNITY` section and click on `Enable Community` -3. Click on `Get Started` and fill out the necessary info - -Once the previous steps are done, all that is left is to: -1. Create a new channel -2. Choose the `Forum` type -3. [Copy its ID](../obtaining-discord-ids#channel-id) -4. Add the following line to the `.env.server` file: `channels_python_help={newly_created_forum_channel_id}` - ---- - -### Optional: Working with Redis -In [Configure the Bot](#envserver) you were asked to set `redis_use_fakeredis=true`. If you do not need to work on features that rely on Redis, this is enough. Fakeredis will give the illusion that features relying on Redis are saving information properly, but restarting the bot or the specific cog will wipe that information. - -If you are working on a feature that relies on Redis, you will need to enable Redis to make sure persistency is achieved for the feature across restarts. The first step towards that is going to `.env.server` and setting `redis.use_fakeredis` to `false`. - -#### Starting Redis in Docker (Recommended) -If you're using the Docker image provided in the project's Docker Compose, open your `.env.server` file. If you're running the bot in Docker, set `redis_host=redis`, and if you're running it on the host set it to `localhost`. Set `redis_password=""`. - -#### Starting Redis Using Other Methods -You can run your own instance of Redis, but in that case you will need to correctly set `redis_host` and `redis_port` in your `.env.server` file and the `REDIS_PASSWORD` in the `.env` file. -**Note**: The previously mentioned variables **SHOULD NOT** be overriden or changed in `constants.py` - ---- - -### Optional: Working with Metricity -[Metricity](https://github.com/python-discord/metricity) is our home-grown bot for collecting metrics on activity within the server, such as what users are present, and IDs of the messages they've sent. -Certain features in the Python bot rely on querying the Metricity database for information such as the number of messages a user has sent, most notably the voice verification system. - -If you wish to work on a feature that relies on Metricity, for your convenience we've made the process of using it relatively painless with Docker: Enter the `.env` file you've written for the Python bot, and append the line `USE_METRICITY=true`. -Note that if you don't need Metricity, there's no reason to have it enabled as it is just unnecessary overhead. - -To make the Metricity bot work with your test server, you will need to override its configurations similarly to the Python bot. -You can see the various configurations in [the Metricity repo](https://github.com/python-discord/metricity), but the bare minimum is the guild ID setting. -In your local version of the Python bot repo, create a file called `metricity-config.toml` and insert the following lines: -```yaml -[bot] -guild_id = replace_with_your_guild_id -``` -To properly replicate production behavior, set the `staff_role_id`, `staff_categories`, and `ignore_categories` fields as well. - -Now, `docker compose up` will also start Metricity. - -If you want to run the bot locally, you can run `docker compose up metricity` instead. - ---- - -### Optional: Working with bot moderation logs -To be able to view moderation-related logs published by the bot to site, you will need to set `urls_site_logs_view=http://localhost:8000/staff/bot/logs` in your `.env.server`. -This will work in both Docker and locally. - ---- +### Issues? +If you have any issues with setting up the bot, come discuss it with us on the [#dev-contrib](https://discord.gg/2h3qBv8Xaa) channel on our server. -### Optional: Changing your command prefix -If you would like a prefix other than the default `!`, set `BOT_PREFIX={{your_prefix}}` in `.env.server`. +If you find any bugs in the bot or would like to request a feature, feel free to [open an issue](https://github.com/python-discord/bot/issues/new/choose) on the repository. --- -### Issues? -If you have any issues with setting up the bot, come discuss it with us on the [#dev-contrib](https://discord.gg/2h3qBv8Xaa) channel on our server. +# Next steps +Now that you have everything setup, it is finally time to make changes to the bot! If you have not yet read the [contributing guidelines](../contributing-guidelines.md), now is a good time. Contributions that do not adhere to the guidelines may be rejected. -If you find any bugs in the bot or would like to request a feature, feel free to [open an issue](https://github.com/python-discord/bot/issues/new/choose) on the repository. +Have fun! --- -### Appendix: Full ENV File Options +# Appendix: Full ENV File Options The following is a list of all available environment variables used by the bot: | Variable | Required | Description | @@ -407,17 +193,8 @@ The following is a list of all available environment variables used by the bot: | `BOT_SENTRY_DSN` | When connecting the bot to sentry | The DSN of the sentry monitor. | | `BOT_TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. | | `DEBUG` | In production | `true` or `false`, depending on whether to enable debug mode, affecting the behavior of certain features. `true` by default. | -| `REDIS_PASSWORD` | When not using FakeRedis | The password to connect to the Redis database (see [Optional: Working with Redis](#optional-working-with-redis)). | -| `USE_METRICITY` | When using Metricity | `true` or `false`, depending on whether to enable metrics collection using Metricity (see [Optional: Working with Metricity](#optional-working-with-metricity)). `false` by default. | +| `REDIS_PASSWORD` | When not using FakeRedis | The password to connect to the Redis database (see [Staring Redis with other methods](../bot-extended-configuration-options#starting-redis-using-other-methods)). | +| `USE_METRICITY` | When using Metricity | `true` or `false`, depending on whether to enable metrics collection using Metricity (see [Working with Metricity](../bot-extended-configuration-options#working-with-metricity)). `false` by default. | | `API_KEYS_GITHUB` | When you wish to interact with GitHub | The API key to interact with GitHub, for example to download files for the branding manager. | | `METABASE_USERNAME` | When you wish to interact with Metabase | The username for a Metabase admin account. | | `METABASE_PASSWORD` | When you wish to interact with Metabase | The password for a Metabase admin account. | - ---- - -# Next steps -Now that you have everything setup, it is finally time to make changes to the bot! If you have not yet read the [contributing guidelines](../contributing-guidelines.md), now is a good time. Contributions that do not adhere to the guidelines may be rejected. - -If you're not sure where to go from here, our [detailed walkthrough](../#2-set-up-the-project) is for you. - -Have fun! -- cgit v1.2.3 From aa303ed0776c8deab31890830934a8b0ef833f65 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 22 Apr 2023 14:55:40 +0200 Subject: Make unittests independent of GitHub (#948) This fixes a problem where running the unit tests successively a lot would result in 403 ratelimit exceeded errors being thrown due to the GitHub API being called by the app. Fixes #918 Co-authored-by: wookie184 --- .coveragerc | 2 ++ pydis_site/apps/content/tests/test_views.py | 5 +++-- pydis_site/apps/home/tests/test_views.py | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/.coveragerc b/.coveragerc index 38926b22..b41a2140 100644 --- a/.coveragerc +++ b/.coveragerc @@ -11,6 +11,8 @@ omit = */apps.py */urls.py pydis_site/apps/api/models/bot/metricity.py + # GitHub API functions are mocked away + pydis_site/apps/content/utils.py pydis_site/wsgi.py pydis_site/settings.py pydis_site/utils/resources.py diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py index 3ef9bcc4..e4f898ef 100644 --- a/pydis_site/apps/content/tests/test_views.py +++ b/pydis_site/apps/content/tests/test_views.py @@ -1,6 +1,6 @@ import textwrap from pathlib import Path -from unittest import TestCase +from unittest import TestCase, mock import django.test import markdown @@ -223,7 +223,8 @@ class TagViewTests(django.test.TestCase): def test_invalid_tag_404(self): """Test that a tag which doesn't exist raises a 404.""" - response = self.client.get("/pages/tags/non-existent/") + with mock.patch("pydis_site.apps.content.utils.fetch_tags", autospec=True): + response = self.client.get("/pages/tags/non-existent/") self.assertEqual(404, response.status_code) def test_context_tag(self): diff --git a/pydis_site/apps/home/tests/test_views.py b/pydis_site/apps/home/tests/test_views.py index b1215df4..379b984e 100644 --- a/pydis_site/apps/home/tests/test_views.py +++ b/pydis_site/apps/home/tests/test_views.py @@ -1,3 +1,5 @@ +from unittest import mock + from django.test import TestCase from django.urls import reverse @@ -6,5 +8,6 @@ class TestIndexReturns200(TestCase): def test_index_returns_200(self): """Check that the index page returns a HTTP 200 response.""" url = reverse('home:home') - resp = self.client.get(url) + with mock.patch("pydis_site.apps.home.views.HomeView._get_api_data", autospec=True): + resp = self.client.get(url) self.assertEqual(resp.status_code, 200) -- cgit v1.2.3 From b4e843ebc1fa35c457e59bfff252b252ef2ef77f Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Fri, 19 Nov 2021 10:11:18 +0800 Subject: feat: Edit on GitHub button for content articles - Using `if pages` to check whether the page is an article or category doesn't work for /pages/guides - I still need to modify the styling and position of the button a bit - Should probably just use a static method and put src_url as context instead of having a template tag --- pydis_site/apps/content/templatetags/__init__.py | 0 pydis_site/apps/content/templatetags/page_src.py | 22 ++++++++++++++++++++++ pydis_site/templates/content/base.html | 1 + 3 files changed, 23 insertions(+) create mode 100644 pydis_site/apps/content/templatetags/__init__.py create mode 100644 pydis_site/apps/content/templatetags/page_src.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/templatetags/__init__.py b/pydis_site/apps/content/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pydis_site/apps/content/templatetags/page_src.py b/pydis_site/apps/content/templatetags/page_src.py new file mode 100644 index 00000000..33f24b82 --- /dev/null +++ b/pydis_site/apps/content/templatetags/page_src.py @@ -0,0 +1,22 @@ +from django import template + + +register = template.Library() + + +@register.filter() +def page_src_url(request_path: str) -> str: + """ + Return the corresponding GitHub source URL for the current content article. + + request_path is the relative path of an article, as returned by `request.path` in templates. + + For example: /pages/rules/ would return: + https://github.com/python-discord/site/tree/main/pydis_site/apps/content/resources/rules.md + """ + src_url = request_path.replace( + "/pages/", + "https://github.com/python-discord/site/tree/main/pydis_site/apps/content/resources/", + ) + src_url = src_url[:-1] + ".md" + return src_url diff --git a/pydis_site/templates/content/base.html b/pydis_site/templates/content/base.html index 2fd721a3..8f6fd1d8 100644 --- a/pydis_site/templates/content/base.html +++ b/pydis_site/templates/content/base.html @@ -1,5 +1,6 @@ {% extends 'base/base.html' %} {% load static %} +{% load page_src %} {% block title %}{{ page_title }}{% endblock %} {% block head %} -- cgit v1.2.3 From a34ebfa67838acac9f2651d7eba7a7472a4bd4ec Mon Sep 17 00:00:00 2001 From: hedy Date: Sat, 29 Oct 2022 10:37:45 +0800 Subject: Use content articles source url in django.conf.settings --- pydis_site/apps/content/templatetags/page_src.py | 3 ++- pydis_site/settings.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/templatetags/page_src.py b/pydis_site/apps/content/templatetags/page_src.py index 33f24b82..3a8f1e8b 100644 --- a/pydis_site/apps/content/templatetags/page_src.py +++ b/pydis_site/apps/content/templatetags/page_src.py @@ -1,4 +1,5 @@ from django import template +from django.conf import settings register = template.Library() @@ -16,7 +17,7 @@ def page_src_url(request_path: str) -> str: """ src_url = request_path.replace( "/pages/", - "https://github.com/python-discord/site/tree/main/pydis_site/apps/content/resources/", + settings.CONTENT_SRC_URL, ) src_url = src_url[:-1] + ".md" return src_url diff --git a/pydis_site/settings.py b/pydis_site/settings.py index e9e0ba67..9da9a156 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -332,3 +332,8 @@ REDIRECTIONS_PATH = Path(BASE_DIR, "pydis_site", "apps", "redirect", "redirects. # How long to wait for synchronous requests before timing out TIMEOUT_PERIOD = env("TIMEOUT_PERIOD") + +# Source files url for 'Edit on GitHub' link on content articles +CONTENT_SRC_URL = ( + "https://github.com/python-discord/site/tree/main/pydis_site/apps/content/resources/" +) -- cgit v1.2.3 From e3dcb3e0e965a583c1cb2257207a481800966e1a Mon Sep 17 00:00:00 2001 From: hedy Date: Sat, 29 Oct 2022 10:40:34 +0800 Subject: Simplify line in page_src_url template filter --- pydis_site/apps/content/templatetags/page_src.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/templatetags/page_src.py b/pydis_site/apps/content/templatetags/page_src.py index 3a8f1e8b..261abbdf 100644 --- a/pydis_site/apps/content/templatetags/page_src.py +++ b/pydis_site/apps/content/templatetags/page_src.py @@ -15,9 +15,6 @@ def page_src_url(request_path: str) -> str: For example: /pages/rules/ would return: https://github.com/python-discord/site/tree/main/pydis_site/apps/content/resources/rules.md """ - src_url = request_path.replace( - "/pages/", - settings.CONTENT_SRC_URL, - ) + src_url = request_path.replace("/pages/", settings.CONTENT_SRC_URL) src_url = src_url[:-1] + ".md" return src_url -- cgit v1.2.3 From 5489aeb3bdcda8cc2c9b1965dc182d431d11c23e Mon Sep 17 00:00:00 2001 From: hedy Date: Sat, 29 Oct 2022 10:43:26 +0800 Subject: Improve docstring for page_src_url template filter --- pydis_site/apps/content/templatetags/page_src.py | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/templatetags/page_src.py b/pydis_site/apps/content/templatetags/page_src.py index 261abbdf..143c420c 100644 --- a/pydis_site/apps/content/templatetags/page_src.py +++ b/pydis_site/apps/content/templatetags/page_src.py @@ -12,6 +12,11 @@ def page_src_url(request_path: str) -> str: request_path is the relative path of an article, as returned by `request.path` in templates. + GitHub source URL is set in settings.py as CONTENT_SRC_URL, prefix for the + url which the request path would be appended to. + + Assumes '.md' file extension for the page source files. + For example: /pages/rules/ would return: https://github.com/python-discord/site/tree/main/pydis_site/apps/content/resources/rules.md """ -- cgit v1.2.3 From fb954b18754b139a27f05aab0f11266a6766a497 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Sat, 6 May 2023 13:12:22 +0100 Subject: update all env var names --- .../pydis-guides/contributing/sir-lancebot.md | 22 +++--- .../contributing/sir-lancebot/env-var-reference.md | 92 ++++++++++------------ 2 files changed, 52 insertions(+), 62 deletions(-) (limited to 'pydis_site/apps') 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 edfd7ac1..f4a6dd37 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 @@ -72,17 +72,17 @@ You will have to setup environment variables: The following variables are needed for running Sir Lancebot: -| Environment Variable | Description | -| -------- | -------- | -| `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 | - -[**Full environment variable reference for this project.**](./env-var-reference) +| Environment Variable | Description | +|--------------------------------|--------------------------------------------------------------------------------------------| +| `CLIENT_TOKEN` | Bot Token from the [Discord developer portal](https://discord.com/developers/applications) | +| `CLIENT_GUILD` | ID of the Discord Server | +| `ROLES_ADMIN` | ID of the role `@Admins` | +| `ROLES_HELPERS` | ID of the role `@Helpers` | +| `CHANNELS_ANNOUNCEMENTS` | ID of the `#announcements` channel | +| `CHANNELS_DEVLOG` | ID of the `#dev-log` channel | +| `CHANNEL_SIR_LANCEBOT_PLAYGROUND` | ID of the `#sir-lancebot-playground` channel | + +[**Full environment variable reference for this project.**](../sir-lancebot/env-var-reference) --- 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 3862fb2e..7e321241 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 @@ -6,69 +6,59 @@ toc: 2 ## General Variables The following variables are needed for running Sir Lancebot: -| Environment Variable | Description | -| -------- | -------- | -| `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 | +| Environment Variable | Description | +|------------------------------------|--------------------------------------------------------------------------------------------| +| `CLIENT_TOKEN` | Bot Token from the [Discord developer portal](https://discord.com/developers/applications) | +| `CLIENT_GUILD` | ID of the Discord Server | +| `ROLES_ADMINS` | ID of the role `@Admins` | +| `ROLES_HELPERS` | ID of the role `@Helpers` | +| `CHANNELS_ANNOUNCEMENTS` | ID of the `#announcements` channel | +| `CHANNELS_DEVLOG` | ID of the `#dev-log` channel | +| `CHANNELS_SIR_LANCEBOT_PLAYGROUND` | ID of the `#sir-lancebot-commands` channel | +| `CHANNELS_REDDIT` | ID of the `#reddit` channel | --- ## Debug Variables Additionally, you may find the following environment variables useful during development: -| Environment Variable | Description | -| -------- | -------- | -| `BOT_DEBUG` | Debug mode of the bot | False | -| `PREFIX` | The bot's invocation prefix | `.` | -| `CYCLE_FREQUENCY` | Amount of days between cycling server icon | 3 | -| `MONTH_OVERRIDE` | Integer in range `[0, 12]`, overrides current month w.r.t. seasonal decorators | -| `REDIS_HOST` | The address to connect to for the Redis database. | -| `REDIS_PORT` | | -| `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 sending `\:emoji:` on discord. | +| Environment Variable | Description | +|----------------------------|------------------------------------------------------------------------------------------------------------| +| `CLIENT_DEBUG` | Debug mode of the bot | False | +| `CLIENT_PREFIX` | The bot's invocation prefix | `.` | +| `BRANDING_CYCLE_FREQUENCY` | Amount of days between cycling server icon | 3 | +| `CLIENT_MONTH_OVERRIDE` | Integer in range `[0, 12]`, overrides current month w.r.t. seasonal decorators | +| `REDIS_HOST` | The address to connect to for the Redis database. | +| `REDIS_PORT` | The port on which the Redis database is exposed. | +| `REDIS_PASSWORD` | The password to connect to the Redis database. | +| `REDIS_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 sending `\:emoji:` on discord. | --- ## Tokens/APIs If you will be working with an external service, you might have to set one of these tokens: -| Token | Description | -| -------- | -------- | -| `GITHUB_TOKEN` | Personal access token for GitHub, raises rate limits from 60 to 5000 requests per hour. | -| `GIPHY_TOKEN` | Required for API access. [Docs](https://developers.giphy.com/docs/api) | -| `OMDB_API_KEY` | Required for API access. [Docs](https://www.omdbapi.com/) | -| `REDDIT_CLIENT_ID` | OAuth2 client ID for authenticating with the [reddit API](https://github.com/reddit-archive/reddit/wiki/OAuth2). | -| `REDDIT_SECRET` | OAuth2 secret for authenticating with the reddit API. *Leave empty if you're not using the reddit API.* | -| `REDDIT_WEBHOOK` | Webhook ID for Reddit channel | -| `YOUTUBE_API_KEY` | An OAuth Key or Token are required for API access. [Docs](https://developers.google.com/youtube/v3/docs#calling-the-api) | -| `TMDB_API_KEY` | Required for API access. [Docs](https://developers.themoviedb.org/3/getting-started/introduction) | -| `NASA_API_KEY` | Required for API access. [Docs](https://api.nasa.gov/) | -| `WOLFRAM_API_KEY` | | -| `UNSPLASH_KEY` | Required for API access. Use the `access_token` given by Unsplash. [Docs](https://unsplash.com/documentation) | -| `IGDB_CLIENT_ID` | OAuth2 client ID for authenticating with the [IGDB API](https://api-docs.igdb.com/) | -| `IGDB_CLIENT_SECRET` | OAuth2 secret for authenticating with the IGDB API. *Leave empty if you're not using the IGDB API.* | +| Token | Description | +|-----------------------------|--------------------------------------------------------------------------------------------------------------------------| +| `TOKENS_GITHUB` | Personal access token for GitHub, raises rate limits from 60 to 5000 requests per hour. | +| `TOKENS_GIPHY` | Required for API access. [Docs](https://developers.giphy.com/docs/api) | +| `REDDIT_CLIENT_ID` | OAuth2 client ID for authenticating with the [reddit API](https://github.com/reddit-archive/reddit/wiki/OAuth2). | +| `REDDIT_SECRET` | OAuth2 secret for authenticating with the reddit API. *Leave empty if you're not using the reddit API.* | +| `REDDIT_WEBHOOK` | Webhook ID for Reddit channel | +| `TOKENS_YOUTUBE` | An OAuth Key or Token are required for API access. [Docs](https://developers.google.com/youtube/v3/docs#calling-the-api) | +| `TOKENS_TMDB` | Required for API access. [Docs](https://developers.themoviedb.org/3/getting-started/introduction) | +| `TOKENS_NASA` | Required for API access. [Docs](https://api.nasa.gov/) | +| `WOLFRAM_KEY` | Required for API access. [Docs](https://products.wolframalpha.com/simple-api/documentation) | +| `TOKENS_UNSPLASH` | Required for API access. Use the `access_token` given by Unsplash. [Docs](https://unsplash.com/documentation) | +| `TOKENS_IGDB_CLIENT_ID` | OAuth2 client ID for authenticating with the [IGDB API](https://api-docs.igdb.com/) | +| `TOKENS_IGDB_CLIENT_SECRET` | OAuth2 secret for authenticating with the IGDB API. *Leave empty if you're not using the IGDB API.* | --- ## Seasonal Cogs These variables might come in handy while working on certain cogs: -| Cog | Environment Variable | Description | -| -------- | -------- | -------- | -| Advent of Code | `AOC_LEADERBOARDS` | List of leaderboards separated by `::`. Each entry should have an `id,session cookie,join code` separated by commas in that order. | -| Advent of Code | `AOC_STAFF_LEADERBOARD_ID` | Integer ID of the staff leaderboard. | -| 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_FALLBACK_SESSION` | | -| Advent of Code | `AOC_SESSION_COOKIE` | | -| Valentines | `LOVEFEST_ROLE_ID` | | -| Wolfram | `WOLFRAM_USER_LIMIT_DAY` | | -| Wolfram | `WOLFRAM_GUILD_LIMIT_DAY` | | +| Cog | Environment Variable | Description | +|------------|---------------------------|-----------------------------------------------------------------| +| Valentines | `ROLES_LOVEFEST` | ID of the role `@Lovefest` | +| Wolfram | `WOLFRAM_USER_LIMIT_DAY` | The amount of requests a user can make per day | +| Wolfram | `WOLFRAM_GUILD_LIMIT_DAY` | The amount of requests that can come from the say guild per day | -- cgit v1.2.3 From 88c52b4f7b4bc8242905e2d1818f5483494aa5ee Mon Sep 17 00:00:00 2001 From: shtlrs Date: Sun, 7 May 2023 10:39:19 +0100 Subject: remove defaults column fix sir lancebot pg env var name --- .../guides/pydis-guides/contributing/sir-lancebot.md | 18 +++++++++--------- .../contributing/sir-lancebot/env-var-reference.md | 16 ++++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) (limited to 'pydis_site/apps') 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 f4a6dd37..b88f2810 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 @@ -72,15 +72,15 @@ You will have to setup environment variables: The following variables are needed for running Sir Lancebot: -| Environment Variable | Description | -|--------------------------------|--------------------------------------------------------------------------------------------| -| `CLIENT_TOKEN` | Bot Token from the [Discord developer portal](https://discord.com/developers/applications) | -| `CLIENT_GUILD` | ID of the Discord Server | -| `ROLES_ADMIN` | ID of the role `@Admins` | -| `ROLES_HELPERS` | ID of the role `@Helpers` | -| `CHANNELS_ANNOUNCEMENTS` | ID of the `#announcements` channel | -| `CHANNELS_DEVLOG` | ID of the `#dev-log` channel | -| `CHANNEL_SIR_LANCEBOT_PLAYGROUND` | ID of the `#sir-lancebot-playground` channel | +| Environment Variable | Description | +|------------------------------------|--------------------------------------------------------------------------------------------| +| `CLIENT_TOKEN` | Bot Token from the [Discord developer portal](https://discord.com/developers/applications) | +| `CLIENT_GUILD` | ID of the Discord Server | +| `ROLES_ADMIN` | ID of the role `@Admins` | +| `ROLES_HELPERS` | ID of the role `@Helpers` | +| `CHANNELS_ANNOUNCEMENTS` | ID of the `#announcements` channel | +| `CHANNELS_DEVLOG` | ID of the `#dev-log` channel | +| `CHANNELS_SIR_LANCEBOT_PLAYGROUND` | ID of the `#sir-lancebot-playground` channel | [**Full environment variable reference for this project.**](../sir-lancebot/env-var-reference) 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 7e321241..629394d5 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 @@ -23,9 +23,9 @@ Additionally, you may find the following environment variables useful during dev | Environment Variable | Description | |----------------------------|------------------------------------------------------------------------------------------------------------| -| `CLIENT_DEBUG` | Debug mode of the bot | False | -| `CLIENT_PREFIX` | The bot's invocation prefix | `.` | -| `BRANDING_CYCLE_FREQUENCY` | Amount of days between cycling server icon | 3 | +| `CLIENT_DEBUG` | Debug mode of the bot | +| `CLIENT_PREFIX` | The bot's invocation prefix | +| `BRANDING_CYCLE_FREQUENCY` | Amount of days between cycling server icon | | `CLIENT_MONTH_OVERRIDE` | Integer in range `[0, 12]`, overrides current month w.r.t. seasonal decorators | | `REDIS_HOST` | The address to connect to for the Redis database. | | `REDIS_PORT` | The port on which the Redis database is exposed. | @@ -57,8 +57,8 @@ If you will be working with an external service, you might have to set one of th ## Seasonal Cogs These variables might come in handy while working on certain cogs: -| Cog | Environment Variable | Description | -|------------|---------------------------|-----------------------------------------------------------------| -| Valentines | `ROLES_LOVEFEST` | ID of the role `@Lovefest` | -| Wolfram | `WOLFRAM_USER_LIMIT_DAY` | The amount of requests a user can make per day | -| Wolfram | `WOLFRAM_GUILD_LIMIT_DAY` | The amount of requests that can come from the say guild per day | +| Cog | Environment Variable | Description | +|------------|---------------------------|------------------------------------------------------------------| +| Valentines | `ROLES_LOVEFEST` | ID of the role `@Lovefest` | +| Wolfram | `WOLFRAM_USER_LIMIT_DAY` | The amount of requests a user can make per day | +| Wolfram | `WOLFRAM_GUILD_LIMIT_DAY` | The amount of requests that can come from the same guild per day | -- cgit v1.2.3 From 1e99244f06b710e5e53da2336527fcd1cc80786a Mon Sep 17 00:00:00 2001 From: shtlrs Date: Sun, 7 May 2023 10:42:19 +0100 Subject: remove unused cycle_frequency env var --- .../guides/pydis-guides/contributing/sir-lancebot/env-var-reference.md | 1 - 1 file changed, 1 deletion(-) (limited to 'pydis_site/apps') 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 629394d5..342da12b 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 @@ -25,7 +25,6 @@ Additionally, you may find the following environment variables useful during dev |----------------------------|------------------------------------------------------------------------------------------------------------| | `CLIENT_DEBUG` | Debug mode of the bot | | `CLIENT_PREFIX` | The bot's invocation prefix | -| `BRANDING_CYCLE_FREQUENCY` | Amount of days between cycling server icon | | `CLIENT_MONTH_OVERRIDE` | Integer in range `[0, 12]`, overrides current month w.r.t. seasonal decorators | | `REDIS_HOST` | The address to connect to for the Redis database. | | `REDIS_PORT` | The port on which the Redis database is exposed. | -- cgit v1.2.3 From dda44dde4c61ee010de0433fb020edb2e87c35d7 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Sun, 7 May 2023 15:59:34 +0100 Subject: update forgotten mentions of sir-lancebot-commands channel This also updates: - docker compose commands - use fake redis env var --- .../resources/guides/pydis-guides/contributing/sir-lancebot.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') 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 b88f2810..7861c3d9 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 @@ -57,9 +57,10 @@ You will need your own test server and bot account on Discord to test your chang 3. Create the following text channels: * `#announcements` * `#dev-log` - * `#sir-lancebot-commands` + * `#sir-lancebot-playground` 4. Create the following roles: * `@Admins` + * `@Helpers` 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) @@ -86,7 +87,7 @@ The following variables are needed for running Sir Lancebot: --- -While not required, we advise you set `USE_FAKEREDIS` to `true` in development to avoid the need of setting up a Redis server. +While not required, we advise you set `REDIS_USE_FAKEREDIS` to `true` in development to avoid the need of setting up a Redis server. It does mean you may lose persistent data on restart but this is non-critical. Otherwise, please see the below linked guide for Redis related variables. {: .notification .is-warning } @@ -96,11 +97,11 @@ Otherwise, please see the below linked guide for Redis related variables. The sections below describe the two ways you can run this project. We recommend Docker as it requires less setup. ## Run with Docker -Make sure to have Docker running, then use the Docker command `docker-compose up` in the project root. +Make sure to have Docker running, then use the Docker command `docker compose up` in the project root. The first time you run this command, it may take a few minutes while Docker downloads and installs Sir Lancebot's dependencies. ```shell -$ docker-compose up +$ docker compose up ``` If you get any Docker related errors, reference the [Possible Issues](../docker#possible-issues) section of the Docker page. -- cgit v1.2.3 From 07855963a1eedd80c410ab2dd51fcae1200c9cee Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 10 May 2023 12:30:57 +0200 Subject: Switch to ruff for linting --- .github/workflows/lint-test.yaml | 17 +- .pre-commit-config.yaml | 12 +- poetry.lock | 700 ++++++--------------- pydis_site/apps/api/admin.py | 29 +- pydis_site/apps/api/github_utils.py | 13 +- pydis_site/apps/api/models/bot/message.py | 7 +- pydis_site/apps/api/models/bot/metricity.py | 19 +- pydis_site/apps/api/tests/base.py | 3 +- pydis_site/apps/api/tests/test_bumped_threads.py | 2 +- pydis_site/apps/api/tests/test_deleted_messages.py | 11 +- .../apps/api/tests/test_documentation_links.py | 2 +- pydis_site/apps/api/tests/test_filters.py | 12 +- pydis_site/apps/api/tests/test_github_utils.py | 41 +- pydis_site/apps/api/tests/test_infractions.py | 110 ++-- pydis_site/apps/api/tests/test_models.py | 4 +- pydis_site/apps/api/tests/test_nominations.py | 2 +- .../apps/api/tests/test_off_topic_channel_names.py | 2 +- .../apps/api/tests/test_offensive_message.py | 10 +- pydis_site/apps/api/tests/test_reminders.py | 4 +- pydis_site/apps/api/tests/test_roles.py | 2 +- pydis_site/apps/api/tests/test_rules.py | 2 +- pydis_site/apps/api/tests/test_users.py | 29 +- pydis_site/apps/api/tests/test_validators.py | 4 +- pydis_site/apps/api/views.py | 4 +- pydis_site/apps/api/viewsets/bot/filters.py | 4 +- .../api/viewsets/bot/off_topic_channel_name.py | 7 +- pydis_site/apps/api/viewsets/bot/user.py | 5 +- pydis_site/apps/content/models/tag.py | 3 +- .../guides/pydis-guides/contributing/linting.md | 2 +- pydis_site/apps/content/tests/helpers.py | 20 +- pydis_site/apps/content/urls.py | 2 +- pydis_site/apps/content/utils.py | 16 +- pydis_site/apps/content/views/tags.py | 3 +- pydis_site/apps/events/urls.py | 2 +- pydis_site/apps/events/views/page.py | 3 +- .../apps/home/tests/test_repodata_helpers.py | 2 +- pydis_site/apps/home/views.py | 13 +- pydis_site/apps/redirect/urls.py | 25 +- pydis_site/apps/redirect/views.py | 3 +- pydis_site/apps/resources/views.py | 7 +- .../staff/templatetags/deletedmessage_filters.py | 3 +- .../staff/tests/test_deletedmessage_filters.py | 2 +- pyproject.toml | 39 +- static-builds/netlify_build.py | 6 +- 44 files changed, 445 insertions(+), 763 deletions(-) (limited to 'pydis_site/apps') diff --git a/.github/workflows/lint-test.yaml b/.github/workflows/lint-test.yaml index 1c744470..a12fb650 100644 --- a/.github/workflows/lint-test.yaml +++ b/.github/workflows/lint-test.yaml @@ -24,20 +24,11 @@ jobs: # We will not run `flake8` here, as we will use a separate flake8 # action. - name: Run pre-commit hooks - run: SKIP=flake8 pre-commit run --all-files + run: SKIP=ruff pre-commit run --all-files - # Run flake8 and have it format the linting errors in the format of - # the GitHub Workflow command to register error annotations. This - # means that our flake8 output is automatically added as an error - # annotation to both the run result and in the "Files" tab of a - # pull request. - # - # Format used: - # ::error file={filename},line={line},col={col}::{message} - - name: Run flake8 - run: "flake8 \ - --format='::error file=%(path)s,line=%(row)d,col=%(col)d::\ - [flake8] %(code)s: %(text)s'" + # Run `ruff` using github formatting to enable automatic inline annotations. + - name: Run ruff + run: "ruff check --format=github ." - name: Migrations and run tests with coverage.py run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b2a03559..700dd0a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,15 +10,11 @@ repos: args: [--fix=lf] - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.5.1 - hooks: - - id: python-check-blanket-noqa - repo: local hooks: - - id: flake8 - name: Flake8 - description: This hook runs flake8 within our project's environment. - entry: poetry run flake8 + - id: ruff + name: ruff + description: This hook runs ruff within our project's environment. + entry: poetry run ruff language: system types: [python] diff --git a/poetry.lock b/poetry.lock index d6e09cbe..4d9a87de 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. [[package]] name = "anyio" @@ -36,58 +36,16 @@ files = [ [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] -[[package]] -name = "attrs" -version = "22.2.0" -description = "Classes Without Boilerplate" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, - {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, -] - -[package.extras] -cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] -tests = ["attrs[tests-no-zope]", "zope.interface"] -tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] - -[[package]] -name = "bandit" -version = "1.7.4" -description = "Security oriented static analyser for python code." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "bandit-1.7.4-py3-none-any.whl", hash = "sha256:412d3f259dab4077d0e7f0c11f50f650cc7d10db905d98f6520a95a18049658a"}, - {file = "bandit-1.7.4.tar.gz", hash = "sha256:2d63a8c573417bae338962d4b9b06fbc6080f74ecd955a092849e1e65c717bd2"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -GitPython = ">=1.0.1" -PyYAML = ">=5.3.1" -stevedore = ">=1.20.0" - -[package.extras] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml"] -toml = ["toml"] -yaml = ["PyYAML"] - [[package]] name = "certifi" -version = "2022.12.7" +version = "2023.5.7" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, + {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, + {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, ] [[package]] @@ -181,100 +139,87 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.0.1" +version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false -python-versions = "*" -files = [ - {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-win32.whl", hash = "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-win32.whl", hash = "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-win32.whl", hash = "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-win32.whl", hash = "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-win32.whl", hash = "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59"}, - {file = "charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24"}, +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, + {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, ] [[package]] @@ -355,35 +300,31 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "39.0.1" +version = "40.0.2" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:6687ef6d0a6497e2b58e7c5b852b53f62142cfa7cd1555795758934da363a965"}, - {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:706843b48f9a3f9b9911979761c91541e3d90db1ca905fd63fee540a217698bc"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5d2d8b87a490bfcd407ed9d49093793d0f75198a35e6eb1a923ce1ee86c62b41"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e17b26de248c33f3acffb922748151d71827d6021d98c70e6c1a25ddd78505"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e124352fd3db36a9d4a21c1aa27fd5d051e621845cb87fb851c08f4f75ce8be6"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:5aa67414fcdfa22cf052e640cb5ddc461924a045cacf325cd164e65312d99502"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:35f7c7d015d474f4011e859e93e789c87d21f6f4880ebdc29896a60403328f1f"}, - {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f24077a3b5298a5a06a8e0536e3ea9ec60e4c7ac486755e5fb6e6ea9b3500106"}, - {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c64d1bd842ca2633e74a1a28033d139368ad959872533b1bab8c80e8240a0c"}, - {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0f8da300b5c8af9f98111ffd512910bc792b4c77392a9523624680f7956a99d4"}, - {file = "cryptography-39.0.1-cp36-abi3-win32.whl", hash = "sha256:fe913f20024eb2cb2f323e42a64bdf2911bb9738a15dba7d3cce48151034e3a8"}, - {file = "cryptography-39.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ced4e447ae29ca194449a3f1ce132ded8fcab06971ef5f618605aacaa612beac"}, - {file = "cryptography-39.0.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:807ce09d4434881ca3a7594733669bd834f5b2c6d5c7e36f8c00f691887042ad"}, - {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c5caeb8188c24888c90b5108a441c106f7faa4c4c075a2bcae438c6e8ca73cef"}, - {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4789d1e3e257965e960232345002262ede4d094d1a19f4d3b52e48d4d8f3b885"}, - {file = "cryptography-39.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:96f1157a7c08b5b189b16b47bc9db2332269d6680a196341bf30046330d15388"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e422abdec8b5fa8462aa016786680720d78bdce7a30c652b7fadf83a4ba35336"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:b0afd054cd42f3d213bf82c629efb1ee5f22eba35bf0eec88ea9ea7304f511a2"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:6f8ba7f0328b79f08bdacc3e4e66fb4d7aab0c3584e0bd41328dce5262e26b2e"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ef8b72fa70b348724ff1218267e7f7375b8de4e8194d1636ee60510aae104cd0"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:aec5a6c9864be7df2240c382740fcf3b96928c46604eaa7f3091f58b878c0bb6"}, - {file = "cryptography-39.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdd188c8a6ef8769f148f88f859884507b954cc64db6b52f66ef199bb9ad660a"}, - {file = "cryptography-39.0.1.tar.gz", hash = "sha256:d1f6198ee6d9148405e49887803907fe8962a23e6c6f83ea7d98f1c0de375695"}, + {file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:8f79b5ff5ad9d3218afb1e7e20ea74da5f76943ee5edb7f76e56ec5161ec782b"}, + {file = "cryptography-40.0.2-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:05dc219433b14046c476f6f09d7636b92a1c3e5808b9a6536adf4932b3b2c440"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4df2af28d7bedc84fe45bd49bc35d710aede676e2a4cb7fc6d103a2adc8afe4d"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dcca15d3a19a66e63662dc8d30f8036b07be851a8680eda92d079868f106288"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a04386fb7bc85fab9cd51b6308633a3c271e3d0d3eae917eebab2fac6219b6d2"}, + {file = "cryptography-40.0.2-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:adc0d980fd2760c9e5de537c28935cc32b9353baaf28e0814df417619c6c8c3b"}, + {file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d5a1bd0e9e2031465761dfa920c16b0065ad77321d8a8c1f5ee331021fda65e9"}, + {file = "cryptography-40.0.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a95f4802d49faa6a674242e25bfeea6fc2acd915b5e5e29ac90a32b1139cae1c"}, + {file = "cryptography-40.0.2-cp36-abi3-win32.whl", hash = "sha256:aecbb1592b0188e030cb01f82d12556cf72e218280f621deed7d806afd2113f9"}, + {file = "cryptography-40.0.2-cp36-abi3-win_amd64.whl", hash = "sha256:b12794f01d4cacfbd3177b9042198f3af1c856eedd0a98f10f141385c809a14b"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:142bae539ef28a1c76794cca7f49729e7c54423f615cfd9b0b1fa90ebe53244b"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:956ba8701b4ffe91ba59665ed170a2ebbdc6fc0e40de5f6059195d9f2b33ca0e"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f01c9863da784558165f5d4d916093737a75203a5c5286fde60e503e4276c7a"}, + {file = "cryptography-40.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3daf9b114213f8ba460b829a02896789751626a2a4e7a43a28ee77c04b5e4958"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48f388d0d153350f378c7f7b41497a54ff1513c816bcbbcafe5b829e59b9ce5b"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c0764e72b36a3dc065c155e5b22f93df465da9c39af65516fe04ed3c68c92636"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cbaba590180cba88cb99a5f76f90808a624f18b169b90a4abb40c1fd8c19420e"}, + {file = "cryptography-40.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7a38250f433cd41df7fcb763caa3ee9362777fdb4dc642b9a349721d2bf47404"}, + {file = "cryptography-40.0.2.tar.gz", hash = "sha256:c33c0d32b8594fa647d2e01dbccc303478e16fdd7cf98652d5b3ed11aa5e5c99"}, ] [package.dependencies] @@ -392,10 +333,10 @@ cffi = ">=1.12" [package.extras] docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] -pep8test = ["black", "check-manifest", "mypy", "ruff", "types-pytz", "types-requests"] +pep8test = ["black", "check-manifest", "mypy", "ruff"] sdist = ["setuptools-rust (>=0.11.4)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist", "pytz"] +test = ["iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist"] test-randomorder = ["pytest-randomly"] tox = ["tox"] @@ -536,193 +477,19 @@ pytz = "*" [[package]] name = "filelock" -version = "3.9.0" +version = "3.12.0" description = "A platform independent file lock." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "filelock-3.9.0-py3-none-any.whl", hash = "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d"}, - {file = "filelock-3.9.0.tar.gz", hash = "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de"}, + {file = "filelock-3.12.0-py3-none-any.whl", hash = "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9"}, + {file = "filelock-3.12.0.tar.gz", hash = "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"}, ] [package.extras] -docs = ["furo (>=2022.12.7)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] -testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"] - -[[package]] -name = "flake8" -version = "6.0.0" -description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, - {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.10.0,<2.11.0" -pyflakes = ">=3.0.0,<3.1.0" - -[[package]] -name = "flake8-annotations" -version = "3.0.1" -description = "Flake8 Type Annotation Checks" -category = "dev" -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "flake8_annotations-3.0.1-py3-none-any.whl", hash = "sha256:af78e3216ad800d7e144745ece6df706c81b3255290cbf870e54879d495e8ade"}, - {file = "flake8_annotations-3.0.1.tar.gz", hash = "sha256:ff37375e71e3b83f2a5a04d443c41e2c407de557a884f3300a7fa32f3c41cb0a"}, -] - -[package.dependencies] -attrs = ">=21.4" -flake8 = ">=5.0" - -[[package]] -name = "flake8-bandit" -version = "4.1.1" -description = "Automated security testing with bandit and flake8." -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "flake8_bandit-4.1.1-py3-none-any.whl", hash = "sha256:4c8a53eb48f23d4ef1e59293657181a3c989d0077c9952717e98a0eace43e06d"}, - {file = "flake8_bandit-4.1.1.tar.gz", hash = "sha256:068e09287189cbfd7f986e92605adea2067630b75380c6b5733dab7d87f9a84e"}, -] - -[package.dependencies] -bandit = ">=1.7.3" -flake8 = ">=5.0.0" - -[[package]] -name = "flake8-bugbear" -version = "23.5.9" -description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." -category = "dev" -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "flake8-bugbear-23.5.9.tar.gz", hash = "sha256:695c84a5d7da54eb35d79a7354dbaf3aaba80de32250608868aa1c85534b2a86"}, - {file = "flake8_bugbear-23.5.9-py3-none-any.whl", hash = "sha256:631fa927fbc799e8ca636b849dd7dfc304812287137b6ecb3277821f028bee40"}, -] - -[package.dependencies] -attrs = ">=19.2.0" -flake8 = ">=6.0.0" - -[package.extras] -dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "pytest", "tox"] - -[[package]] -name = "flake8-docstrings" -version = "1.7.0" -description = "Extension for flake8 which uses pydocstyle to check docstrings" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75"}, - {file = "flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af"}, -] - -[package.dependencies] -flake8 = ">=3" -pydocstyle = ">=2.1" - -[[package]] -name = "flake8-import-order" -version = "0.18.2" -description = "Flake8 and pylama plugin that checks the ordering of import statements." -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "flake8-import-order-0.18.2.tar.gz", hash = "sha256:e23941f892da3e0c09d711babbb0c73bc735242e9b216b726616758a920d900e"}, - {file = "flake8_import_order-0.18.2-py2.py3-none-any.whl", hash = "sha256:82ed59f1083b629b030ee9d3928d9e06b6213eb196fe745b3a7d4af2168130df"}, -] - -[package.dependencies] -pycodestyle = "*" -setuptools = "*" - -[[package]] -name = "flake8-string-format" -version = "0.3.0" -description = "string format checker, plugin for flake8" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "flake8-string-format-0.3.0.tar.gz", hash = "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2"}, - {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, -] - -[package.dependencies] -flake8 = "*" - -[[package]] -name = "flake8-tidy-imports" -version = "4.8.0" -description = "A flake8 plugin that helps you write tidier imports." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "flake8-tidy-imports-4.8.0.tar.gz", hash = "sha256:df44f9c841b5dfb3a7a1f0da8546b319d772c2a816a1afefcce43e167a593d83"}, - {file = "flake8_tidy_imports-4.8.0-py3-none-any.whl", hash = "sha256:25bd9799358edefa0e010ce2c587b093c3aba942e96aeaa99b6d0500ae1bf09c"}, -] - -[package.dependencies] -flake8 = ">=3.8.0" - -[[package]] -name = "flake8-todo" -version = "0.7" -description = "TODO notes checker, plugin for flake8" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"}, -] - -[package.dependencies] -pycodestyle = ">=2.0.0,<3.0.0" - -[[package]] -name = "gitdb" -version = "4.0.10" -description = "Git Object Database" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "gitdb-4.0.10-py3-none-any.whl", hash = "sha256:c286cf298426064079ed96a9e4a9d39e7f3e9bf15ba60701e95f5492f28415c7"}, - {file = "gitdb-4.0.10.tar.gz", hash = "sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a"}, -] - -[package.dependencies] -smmap = ">=3.0.1,<6" - -[[package]] -name = "gitpython" -version = "3.1.31" -description = "GitPython is a Python library used to interact with Git repositories" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "GitPython-3.1.31-py3-none-any.whl", hash = "sha256:f04893614f6aa713a60cbbe1e6a97403ef633103cdd0ef5eb6efe0deb98dbe8d"}, - {file = "GitPython-3.1.31.tar.gz", hash = "sha256:8ce3bcf69adfdf7c7d503e78fd3b1c492af782d58893b650adb2ac8912ddd573"}, -] - -[package.dependencies] -gitdb = ">=4.0.1,<5" +docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] [[package]] name = "gunicorn" @@ -759,14 +526,14 @@ files = [ [[package]] name = "httpcore" -version = "0.16.3" +version = "0.17.0" description = "A minimal low-level HTTP client." category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"}, - {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"}, + {file = "httpcore-0.17.0-py3-none-any.whl", hash = "sha256:0fdfea45e94f0c9fd96eab9286077f9ff788dd186635ae61b312693e4d943599"}, + {file = "httpcore-0.17.0.tar.gz", hash = "sha256:cc045a3241afbf60ce056202301b4d8b6af08845e3294055eb26b09913ef903c"}, ] [package.dependencies] @@ -805,14 +572,14 @@ socks = ["socksio (>=1.0.0,<2.0.0)"] [[package]] name = "identify" -version = "2.5.18" +version = "2.5.24" description = "File identification library for Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "identify-2.5.18-py2.py3-none-any.whl", hash = "sha256:93aac7ecf2f6abf879b8f29a8002d3c6de7086b8c28d88e1ad15045a15ab63f9"}, - {file = "identify-2.5.18.tar.gz", hash = "sha256:89e144fa560cc4cffb6ef2ab5e9fb18ed9f9b3cb054384bab4b95c12f6c309fe"}, + {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, + {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, ] [package.extras] @@ -860,18 +627,6 @@ files = [ [package.extras] testing = ["coverage", "pyyaml"] -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - [[package]] name = "mslex" version = "0.3.0" @@ -899,48 +654,21 @@ files = [ [package.dependencies] setuptools = "*" -[[package]] -name = "pbr" -version = "5.11.1" -description = "Python Build Reasonableness" -category = "dev" -optional = false -python-versions = ">=2.6" -files = [ - {file = "pbr-5.11.1-py2.py3-none-any.whl", hash = "sha256:567f09558bae2b3ab53cb3c1e2e33e726ff3338e7bae3db5dc954b3a44eef12b"}, - {file = "pbr-5.11.1.tar.gz", hash = "sha256:aefc51675b0b533d56bb5fd1c8c6c0522fe31896679882e1c4c63d5e4a0fccb3"}, -] - -[[package]] -name = "pep8-naming" -version = "0.13.3" -description = "Check PEP-8 naming conventions, plugin for flake8" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pep8-naming-0.13.3.tar.gz", hash = "sha256:1705f046dfcd851378aac3be1cd1551c7c1e5ff363bacad707d43007877fa971"}, - {file = "pep8_naming-0.13.3-py3-none-any.whl", hash = "sha256:1a86b8c71a03337c97181917e2b472f0f5e4ccb06844a0d6f0a33522549e7a80"}, -] - -[package.dependencies] -flake8 = ">=5.0.0" - [[package]] name = "platformdirs" -version = "3.0.0" +version = "3.5.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"}, - {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"}, + {file = "platformdirs-3.5.0-py3-none-any.whl", hash = "sha256:47692bc24c1958e8b0f13dd727307cff1db103fca36399f457da8e05f222fdc4"}, + {file = "platformdirs-3.5.0.tar.gz", hash = "sha256:7954a68d0ba23558d753f73437c55f89027cf8f5108c19844d4b82e5af396335"}, ] [package.extras] -docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] [[package]] name = "pre-commit" @@ -978,26 +706,26 @@ twisted = ["twisted"] [[package]] name = "psutil" -version = "5.9.4" +version = "5.9.5" description = "Cross-platform lib for process and system monitoring in Python." category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ - {file = "psutil-5.9.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c1ca331af862803a42677c120aff8a814a804e09832f166f226bfd22b56feee8"}, - {file = "psutil-5.9.4-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:68908971daf802203f3d37e78d3f8831b6d1014864d7a85937941bb35f09aefe"}, - {file = "psutil-5.9.4-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:3ff89f9b835100a825b14c2808a106b6fdcc4b15483141482a12c725e7f78549"}, - {file = "psutil-5.9.4-cp27-cp27m-win32.whl", hash = "sha256:852dd5d9f8a47169fe62fd4a971aa07859476c2ba22c2254d4a1baa4e10b95ad"}, - {file = "psutil-5.9.4-cp27-cp27m-win_amd64.whl", hash = "sha256:9120cd39dca5c5e1c54b59a41d205023d436799b1c8c4d3ff71af18535728e94"}, - {file = "psutil-5.9.4-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6b92c532979bafc2df23ddc785ed116fced1f492ad90a6830cf24f4d1ea27d24"}, - {file = "psutil-5.9.4-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:efeae04f9516907be44904cc7ce08defb6b665128992a56957abc9b61dca94b7"}, - {file = "psutil-5.9.4-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:54d5b184728298f2ca8567bf83c422b706200bcbbfafdc06718264f9393cfeb7"}, - {file = "psutil-5.9.4-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16653106f3b59386ffe10e0bad3bb6299e169d5327d3f187614b1cb8f24cf2e1"}, - {file = "psutil-5.9.4-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54c0d3d8e0078b7666984e11b12b88af2db11d11249a8ac8920dd5ef68a66e08"}, - {file = "psutil-5.9.4-cp36-abi3-win32.whl", hash = "sha256:149555f59a69b33f056ba1c4eb22bb7bf24332ce631c44a319cec09f876aaeff"}, - {file = "psutil-5.9.4-cp36-abi3-win_amd64.whl", hash = "sha256:fd8522436a6ada7b4aad6638662966de0d61d241cb821239b2ae7013d41a43d4"}, - {file = "psutil-5.9.4-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:6001c809253a29599bc0dfd5179d9f8a5779f9dffea1da0f13c53ee568115e1e"}, - {file = "psutil-5.9.4.tar.gz", hash = "sha256:3d7f9739eb435d4b1338944abe23f49584bde5395f27487d2ee25ad9a8774a62"}, + {file = "psutil-5.9.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:be8929ce4313f9f8146caad4272f6abb8bf99fc6cf59344a3167ecd74f4f203f"}, + {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ab8ed1a1d77c95453db1ae00a3f9c50227ebd955437bcf2a574ba8adbf6a74d5"}, + {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:4aef137f3345082a3d3232187aeb4ac4ef959ba3d7c10c33dd73763fbc063da4"}, + {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ea8518d152174e1249c4f2a1c89e3e6065941df2fa13a1ab45327716a23c2b48"}, + {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:acf2aef9391710afded549ff602b5887d7a2349831ae4c26be7c807c0a39fac4"}, + {file = "psutil-5.9.5-cp27-none-win32.whl", hash = "sha256:5b9b8cb93f507e8dbaf22af6a2fd0ccbe8244bf30b1baad6b3954e935157ae3f"}, + {file = "psutil-5.9.5-cp27-none-win_amd64.whl", hash = "sha256:8c5f7c5a052d1d567db4ddd231a9d27a74e8e4a9c3f44b1032762bd7b9fdcd42"}, + {file = "psutil-5.9.5-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3c6f686f4225553615612f6d9bc21f1c0e305f75d7d8454f9b46e901778e7217"}, + {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a7dd9997128a0d928ed4fb2c2d57e5102bb6089027939f3b722f3a210f9a8da"}, + {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89518112647f1276b03ca97b65cc7f64ca587b1eb0278383017c2a0dcc26cbe4"}, + {file = "psutil-5.9.5-cp36-abi3-win32.whl", hash = "sha256:104a5cc0e31baa2bcf67900be36acde157756b9c44017b86b2c049f11957887d"}, + {file = "psutil-5.9.5-cp36-abi3-win_amd64.whl", hash = "sha256:b258c0c1c9d145a1d5ceffab1134441c4c5113b2417fafff7315a917a026c3c9"}, + {file = "psutil-5.9.5-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c607bb3b57dc779d55e1554846352b4e358c10fff3abf3514a7a6601beebdb30"}, + {file = "psutil-5.9.5.tar.gz", hash = "sha256:5410638e4df39c54d957fc51ce03048acd8e6d60abc0f5107af51e5fb566eb3c"}, ] [package.extras] @@ -1075,18 +803,6 @@ files = [ {file = "psycopg2_binary-2.9.6-cp39-cp39-win_amd64.whl", hash = "sha256:f6a88f384335bb27812293fdb11ac6aee2ca3f51d3c7820fe03de0a304ab6249"}, ] -[[package]] -name = "pycodestyle" -version = "2.10.0" -description = "Python style guide checker" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, - {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, -] - [[package]] name = "pycparser" version = "2.21" @@ -1099,36 +815,6 @@ files = [ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] -[[package]] -name = "pydocstyle" -version = "6.3.0" -description = "Python docstring style checker" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, - {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, -] - -[package.dependencies] -snowballstemmer = ">=2.2.0" - -[package.extras] -toml = ["tomli (>=1.2.3)"] - -[[package]] -name = "pyflakes" -version = "3.0.1" -description = "passive checker of Python programs" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, - {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, -] - [[package]] name = "pyjwt" version = "2.7.0" @@ -1202,14 +888,14 @@ test = ["pyaml", "pytest", "toml"] [[package]] name = "pytz" -version = "2022.7.1" +version = "2023.3" description = "World timezone definitions, modern and historical" category = "main" optional = false python-versions = "*" files = [ - {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"}, - {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"}, + {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, + {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, ] [[package]] @@ -1264,26 +950,53 @@ files = [ [[package]] name = "requests" -version = "2.28.2" +version = "2.30.0" description = "Python HTTP for Humans." category = "main" optional = false -python-versions = ">=3.7, <4" +python-versions = ">=3.7" files = [ - {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, - {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, + {file = "requests-2.30.0-py3-none-any.whl", hash = "sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294"}, + {file = "requests-2.30.0.tar.gz", hash = "sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4"}, ] [package.dependencies] certifi = ">=2017.4.17" charset-normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<1.27" +urllib3 = ">=1.21.1,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "ruff" +version = "0.0.265" +description = "An extremely fast Python linter, written in Rust." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.0.265-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:30ddfe22de6ce4eb1260408f4480bbbce998f954dbf470228a21a9b2c45955e4"}, + {file = "ruff-0.0.265-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:a11bd0889e88d3342e7bc514554bb4461bf6cc30ec115821c2425cfaac0b1b6a"}, + {file = "ruff-0.0.265-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a9b38bdb40a998cbc677db55b6225a6c4fadcf8819eb30695e1b8470942426b"}, + {file = "ruff-0.0.265-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8b44a245b60512403a6a03a5b5212da274d33862225c5eed3bcf12037eb19bb"}, + {file = "ruff-0.0.265-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b279fa55ea175ef953208a6d8bfbcdcffac1c39b38cdb8c2bfafe9222add70bb"}, + {file = "ruff-0.0.265-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5028950f7af9b119d43d91b215d5044976e43b96a0d1458d193ef0dd3c587bf8"}, + {file = "ruff-0.0.265-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4057eb539a1d88eb84e9f6a36e0a999e0f261ed850ae5d5817e68968e7b89ed9"}, + {file = "ruff-0.0.265-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d586e69ab5cbf521a1910b733412a5735936f6a610d805b89d35b6647e2a66aa"}, + {file = "ruff-0.0.265-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa17b13cd3f29fc57d06bf34c31f21d043735cc9a681203d634549b0e41047d1"}, + {file = "ruff-0.0.265-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9ac13b11d9ad3001de9d637974ec5402a67cefdf9fffc3929ab44c2fcbb850a1"}, + {file = "ruff-0.0.265-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:62a9578b48cfd292c64ea3d28681dc16b1aa7445b7a7709a2884510fc0822118"}, + {file = "ruff-0.0.265-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d0f9967f84da42d28e3d9d9354cc1575f96ed69e6e40a7d4b780a7a0418d9409"}, + {file = "ruff-0.0.265-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d5a8de2fbaf91ea5699451a06f4074e7a312accfa774ad9327cde3e4fda2081"}, + {file = "ruff-0.0.265-py3-none-win32.whl", hash = "sha256:9e9db5ccb810742d621f93272e3cc23b5f277d8d00c4a79668835d26ccbe48dd"}, + {file = "ruff-0.0.265-py3-none-win_amd64.whl", hash = "sha256:f54facf286103006171a00ce20388d88ed1d6732db3b49c11feb9bf3d46f90e9"}, + {file = "ruff-0.0.265-py3-none-win_arm64.whl", hash = "sha256:c78470656e33d32ddc54e8482b1b0fc6de58f1195586731e5ff1405d74421499"}, + {file = "ruff-0.0.265.tar.gz", hash = "sha256:53c17f0dab19ddc22b254b087d1381b601b155acfa8feed514f0d6a413d0ab3a"}, +] + [[package]] name = "sentry-sdk" version = "1.22.2" @@ -1328,14 +1041,14 @@ tornado = ["tornado (>=5)"] [[package]] name = "setuptools" -version = "67.4.0" +version = "67.7.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "setuptools-67.4.0-py3-none-any.whl", hash = "sha256:f106dee1b506dee5102cc3f3e9e68137bbad6d47b616be7991714b0c62204251"}, - {file = "setuptools-67.4.0.tar.gz", hash = "sha256:e5fd0a713141a4a105412233c63dc4e17ba0090c8e8334594ac790ec97792330"}, + {file = "setuptools-67.7.2-py3-none-any.whl", hash = "sha256:23aaf86b85ca52ceb801d32703f12d77517b2556af839621c641fca11287952b"}, + {file = "setuptools-67.7.2.tar.gz", hash = "sha256:f104fa03692a2602fa0fec6c6a9e63b6c8a968de13e17c026957dd1f53d80990"}, ] [package.extras] @@ -1343,18 +1056,6 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-g testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] -[[package]] -name = "smmap" -version = "5.0.0" -description = "A pure Python implementation of a sliding window memory map manager" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, - {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, -] - [[package]] name = "sniffio" version = "1.3.0" @@ -1367,18 +1068,6 @@ files = [ {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] -[[package]] -name = "snowballstemmer" -version = "2.2.0" -description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] - [[package]] name = "sqlparse" version = "0.4.4" @@ -1396,21 +1085,6 @@ dev = ["build", "flake8"] doc = ["sphinx"] test = ["pytest", "pytest-cov"] -[[package]] -name = "stevedore" -version = "5.0.0" -description = "Manage dynamic plugins for Python applications" -category = "dev" -optional = false -python-versions = ">=3.8" -files = [ - {file = "stevedore-5.0.0-py3-none-any.whl", hash = "sha256:bd5a71ff5e5e5f5ea983880e4a1dd1bb47f8feebbb3d95b592398e2f02194771"}, - {file = "stevedore-5.0.0.tar.gz", hash = "sha256:2c428d2338976279e8eb2196f7a94910960d9f7ba2f41f3988511e95ca447021"}, -] - -[package.dependencies] -pbr = ">=2.0.0,<2.1.0 || >2.1.0" - [[package]] name = "taskipy" version = "1.10.4" @@ -1443,26 +1117,26 @@ files = [ [[package]] name = "tzdata" -version = "2022.7" +version = "2023.3" description = "Provider of IANA time zone data" category = "main" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2022.7-py2.py3-none-any.whl", hash = "sha256:2b88858b0e3120792a3c0635c23daf36a7d7eeeca657c323da299d2094402a0d"}, - {file = "tzdata-2022.7.tar.gz", hash = "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa"}, + {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, + {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, ] [[package]] name = "urllib3" -version = "1.26.14" +version = "1.26.15" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ - {file = "urllib3-1.26.14-py2.py3-none-any.whl", hash = "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1"}, - {file = "urllib3-1.26.14.tar.gz", hash = "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72"}, + {file = "urllib3-1.26.15-py2.py3-none-any.whl", hash = "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42"}, + {file = "urllib3-1.26.15.tar.gz", hash = "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305"}, ] [package.extras] @@ -1472,24 +1146,24 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.19.0" +version = "20.23.0" description = "Virtual Python Environment builder" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.19.0-py3-none-any.whl", hash = "sha256:54eb59e7352b573aa04d53f80fc9736ed0ad5143af445a1e539aada6eb947dd1"}, - {file = "virtualenv-20.19.0.tar.gz", hash = "sha256:37a640ba82ed40b226599c522d411e4be5edb339a0c0de030c0dc7b646d61590"}, + {file = "virtualenv-20.23.0-py3-none-any.whl", hash = "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e"}, + {file = "virtualenv-20.23.0.tar.gz", hash = "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924"}, ] [package.dependencies] distlib = ">=0.3.6,<1" -filelock = ">=3.4.1,<4" -platformdirs = ">=2.4,<4" +filelock = ">=3.11,<4" +platformdirs = ">=3.2,<4" [package.extras] -docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] -test = ["covdefaults (>=2.2.2)", "coverage (>=7.1)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23)", "pytest (>=7.2.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)"] +docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.7.1)", "time-machine (>=2.9)"] [[package]] name = "whitenoise" @@ -1509,4 +1183,4 @@ brotli = ["Brotli"] [metadata] lock-version = "2.0" python-versions = "3.10.*" -content-hash = "a89b60823f9d7717b78c1071654c0c1f237768669a8e37e76175730de209e804" +content-hash = "f9076a1d72b610e77d0c389a4992c814a4c81028232a1ec4f060f77f2cb9125c" diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py index e123d150..f3cc0405 100644 --- a/pydis_site/apps/api/admin.py +++ b/pydis_site/apps/api/admin.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import Iterable, Optional, Tuple +from collections.abc import Iterable from django import urls from django.contrib import admin @@ -62,16 +62,16 @@ class InfractionActorFilter(admin.SimpleListFilter): title = "Actor" parameter_name = "actor" - def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]: + def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[tuple[int, str]]: """Selectable values for viewer to filter by.""" actor_ids = Infraction.objects.order_by().values_list("actor").distinct() actors = User.objects.filter(id__in=actor_ids) return ((a.id, a.username) for a in actors) - def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]: + def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None: """Query to filter the list of Users against.""" if not self.value(): - return + return None return queryset.filter(actor__id=self.value()) @@ -149,7 +149,7 @@ class DeletedMessageAdmin(admin.ModelAdmin): list_display = ("id", "author", "channel_id") - def embed_data(self, message: DeletedMessage) -> Optional[str]: + def embed_data(self, message: DeletedMessage) -> str | None: """Format embed data in a code block for better readability.""" if message.embeds: return format_html( @@ -157,6 +157,7 @@ class DeletedMessageAdmin(admin.ModelAdmin): "{0}", json.dumps(message.embeds, indent=4) ) + return None embed_data.short_description = "Embeds" @@ -229,16 +230,16 @@ class NominationActorFilter(admin.SimpleListFilter): title = "Actor" parameter_name = "actor" - def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]: + def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[tuple[int, str]]: """Selectable values for viewer to filter by.""" actor_ids = NominationEntry.objects.order_by().values_list("actor").distinct() actors = User.objects.filter(id__in=actor_ids) return ((a.id, a.username) for a in actors) - def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]: + def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None: """Query to filter the list of Users against.""" if not self.value(): - return + return None nomination_ids = NominationEntry.objects.filter( actor__id=self.value() ).values_list("nomination_id").distinct() @@ -292,16 +293,16 @@ class NominationEntryActorFilter(admin.SimpleListFilter): title = "Actor" parameter_name = "actor" - def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]: + def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[tuple[int, str]]: """Selectable values for viewer to filter by.""" actor_ids = NominationEntry.objects.order_by().values_list("actor").distinct() actors = User.objects.filter(id__in=actor_ids) return ((a.id, a.username) for a in actors) - def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]: + def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None: """Query to filter the list of Users against.""" if not self.value(): - return + return None return queryset.filter(actor__id=self.value()) @@ -425,15 +426,15 @@ class UserRoleFilter(admin.SimpleListFilter): title = "Role" parameter_name = "role" - def lookups(self, request: HttpRequest, model: UserAdmin) -> Iterable[Tuple[str, str]]: + def lookups(self, request: HttpRequest, model: UserAdmin) -> Iterable[tuple[str, str]]: """Selectable values for viewer to filter by.""" roles = Role.objects.all() return ((r.name, r.name) for r in roles) - def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]: + def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None: """Query to filter the list of Users against.""" if not self.value(): - return + return None role = Role.objects.get(name=self.value()) return queryset.filter(roles__contains=[role.id]) diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py index 44c571c3..af659195 100644 --- a/pydis_site/apps/api/github_utils.py +++ b/pydis_site/apps/api/github_utils.py @@ -82,7 +82,7 @@ def generate_token() -> str: Refer to: https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app """ - now = datetime.datetime.now() + now = datetime.datetime.now(tz=datetime.timezone.utc) return jwt.encode( { "iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()), # Issued at @@ -145,8 +145,12 @@ def authorize(owner: str, repo: str) -> httpx.Client: def check_run_status(run: WorkflowRun) -> str: """Check if the provided run has been completed, otherwise raise an exception.""" - created_at = datetime.datetime.strptime(run.created_at, settings.GITHUB_TIMESTAMP_FORMAT) - run_time = datetime.datetime.utcnow() - created_at + created_at = ( + datetime.datetime + .strptime(run.created_at, settings.GITHUB_TIMESTAMP_FORMAT) + .replace(tzinfo=datetime.timezone.utc) + ) + run_time = datetime.datetime.now(tz=datetime.timezone.utc) - created_at if run.status != "completed": if run_time <= MAX_RUN_TIME: @@ -154,8 +158,7 @@ def check_run_status(run: WorkflowRun) -> str: f"The requested run is still pending. It was created " f"{run_time.seconds // 60}:{run_time.seconds % 60 :>02} minutes ago." ) - else: - raise RunTimeoutError("The requested workflow was not ready in time.") + raise RunTimeoutError("The requested workflow was not ready in time.") if run.conclusion != "success": # The action failed, or did not run diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index 89ae27e4..d8147cd4 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -61,9 +61,10 @@ class Message(ModelReprMixin, models.Model): @property def timestamp(self) -> datetime.datetime: """Attribute that represents the message timestamp as derived from the snowflake id.""" - return datetime.datetime.utcfromtimestamp( - ((self.id >> 22) + 1420070400000) / 1000 - ).replace(tzinfo=datetime.timezone.utc) + return datetime.datetime.fromtimestamp( + ((self.id >> 22) + 1420070400000) / 1000, + tz=datetime.timezone.utc, + ) class Meta: """Metadata provided for Django's ORM.""" diff --git a/pydis_site/apps/api/models/bot/metricity.py b/pydis_site/apps/api/models/bot/metricity.py index f53dd33c..a55f5e5b 100644 --- a/pydis_site/apps/api/models/bot/metricity.py +++ b/pydis_site/apps/api/models/bot/metricity.py @@ -1,4 +1,3 @@ -from typing import List, Tuple from django.db import connections @@ -10,10 +9,9 @@ EXCLUDE_CHANNELS = ( ) -class NotFoundError(Exception): # noqa: N818 +class NotFoundError(Exception): """Raised when an entity cannot be found.""" - pass class Metricity: @@ -31,15 +29,14 @@ class Metricity: def user(self, user_id: str) -> dict: """Query a user's data.""" # TODO: Swap this back to some sort of verified at date - columns = ["joined_at"] - query = f"SELECT {','.join(columns)} FROM users WHERE id = '%s'" + query = "SELECT joined_at FROM users WHERE id = '%s'" self.cursor.execute(query, [user_id]) values = self.cursor.fetchone() if not values: - raise NotFoundError() + raise NotFoundError - return dict(zip(columns, values)) + return {'joined_at': values[0]} def total_messages(self, user_id: str) -> int: """Query total number of messages for a user.""" @@ -58,7 +55,7 @@ class Metricity: values = self.cursor.fetchone() if not values: - raise NotFoundError() + raise NotFoundError return values[0] @@ -88,11 +85,11 @@ class Metricity: values = self.cursor.fetchone() if not values: - raise NotFoundError() + raise NotFoundError return values[0] - def top_channel_activity(self, user_id: str) -> List[Tuple[str, int]]: + def top_channel_activity(self, user_id: str) -> list[tuple[str, int]]: """ Query the top three channels in which the user is most active. @@ -127,7 +124,7 @@ class Metricity: values = self.cursor.fetchall() if not values: - raise NotFoundError() + raise NotFoundError return values diff --git a/pydis_site/apps/api/tests/base.py b/pydis_site/apps/api/tests/base.py index c9f3cb7e..704b22cf 100644 --- a/pydis_site/apps/api/tests/base.py +++ b/pydis_site/apps/api/tests/base.py @@ -61,6 +61,7 @@ class AuthenticatedAPITestCase(APITestCase): ... self.assertEqual(response.status_code, 200) """ - def setUp(self): + def setUp(self) -> None: + """Bootstrap the user and authenticate it.""" super().setUp() self.client.force_authenticate(test_user) diff --git a/pydis_site/apps/api/tests/test_bumped_threads.py b/pydis_site/apps/api/tests/test_bumped_threads.py index 316e3f0b..2e3892c7 100644 --- a/pydis_site/apps/api/tests/test_bumped_threads.py +++ b/pydis_site/apps/api/tests/test_bumped_threads.py @@ -1,7 +1,7 @@ from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import BumpedThread +from pydis_site.apps.api.models import BumpedThread class UnauthedBumpedThreadAPITests(AuthenticatedAPITestCase): diff --git a/pydis_site/apps/api/tests/test_deleted_messages.py b/pydis_site/apps/api/tests/test_deleted_messages.py index 1eb535d8..62d17e58 100644 --- a/pydis_site/apps/api/tests/test_deleted_messages.py +++ b/pydis_site/apps/api/tests/test_deleted_messages.py @@ -1,10 +1,9 @@ -from datetime import datetime +from datetime import datetime, timezone from django.urls import reverse -from django.utils import timezone from .base import AuthenticatedAPITestCase -from ..models import MessageDeletionContext, User +from pydis_site.apps.api.models import MessageDeletionContext, User class DeletedMessagesWithoutActorTests(AuthenticatedAPITestCase): @@ -18,7 +17,7 @@ class DeletedMessagesWithoutActorTests(AuthenticatedAPITestCase): cls.data = { 'actor': None, - 'creation': datetime.utcnow().isoformat(), + 'creation': datetime.now(tz=timezone.utc).isoformat(), 'deletedmessage_set': [ { 'author': cls.author.id, @@ -58,7 +57,7 @@ class DeletedMessagesWithActorTests(AuthenticatedAPITestCase): cls.data = { 'actor': cls.actor.id, - 'creation': datetime.utcnow().isoformat(), + 'creation': datetime.now(tz=timezone.utc).isoformat(), 'deletedmessage_set': [ { 'author': cls.author.id, @@ -90,7 +89,7 @@ class DeletedMessagesLogURLTests(AuthenticatedAPITestCase): cls.deletion_context = MessageDeletionContext.objects.create( actor=cls.actor, - creation=timezone.now() + creation=datetime.now(tz=timezone.utc), ) def test_valid_log_url(self): diff --git a/pydis_site/apps/api/tests/test_documentation_links.py b/pydis_site/apps/api/tests/test_documentation_links.py index 4e238cbb..f4a332cb 100644 --- a/pydis_site/apps/api/tests/test_documentation_links.py +++ b/pydis_site/apps/api/tests/test_documentation_links.py @@ -1,7 +1,7 @@ from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import DocumentationLink +from pydis_site.apps.api.models import DocumentationLink class UnauthedDocumentationLinkAPITests(AuthenticatedAPITestCase): diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py index 5059d651..4cef1c8f 100644 --- a/pydis_site/apps/api/tests/test_filters.py +++ b/pydis_site/apps/api/tests/test_filters.py @@ -1,7 +1,7 @@ import contextlib from dataclasses import dataclass from datetime import timedelta -from typing import Any, Dict, Tuple, Type +from typing import Any from django.db.models import Model from django.urls import reverse @@ -12,22 +12,22 @@ from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase @dataclass() class TestSequence: - model: Type[Model] + model: type[Model] route: str - object: Dict[str, Any] - ignored_fields: Tuple[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, ...]] = { +FK_FIELDS: dict[type[Model], tuple[str, ...]] = { FilterList: (), Filter: ("filter_list",), } -def get_test_sequences() -> Dict[str, TestSequence]: +def get_test_sequences() -> dict[str, TestSequence]: filter_list1_deny_dict = { "name": "testname", "list_type": 0, diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py index 95bafec0..34fae875 100644 --- a/pydis_site/apps/api/tests/test_github_utils.py +++ b/pydis_site/apps/api/tests/test_github_utils.py @@ -12,7 +12,7 @@ import rest_framework.test from django.urls import reverse from pydis_site import settings -from .. import github_utils +from pydis_site.apps.api import github_utils class GeneralUtilityTests(unittest.TestCase): @@ -39,7 +39,8 @@ class GeneralUtilityTests(unittest.TestCase): delta = datetime.timedelta(minutes=10) self.assertAlmostEqual(decoded["exp"] - decoded["iat"], delta.total_seconds()) - self.assertLess(decoded["exp"], (datetime.datetime.now() + delta).timestamp()) + then = datetime.datetime.now(tz=datetime.timezone.utc) + delta + self.assertLess(decoded["exp"], then.timestamp()) class CheckRunTests(unittest.TestCase): @@ -50,7 +51,7 @@ class CheckRunTests(unittest.TestCase): "head_sha": "sha", "status": "completed", "conclusion": "success", - "created_at": datetime.datetime.utcnow().strftime(settings.GITHUB_TIMESTAMP_FORMAT), + "created_at": datetime.datetime.now(tz=datetime.timezone.utc).strftime(settings.GITHUB_TIMESTAMP_FORMAT), "artifacts_url": "url", } @@ -74,7 +75,8 @@ class CheckRunTests(unittest.TestCase): # Set the creation time to well before the MAX_RUN_TIME # to guarantee the right conclusion kwargs["created_at"] = ( - datetime.datetime.utcnow() - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10) + datetime.datetime.now(tz=datetime.timezone.utc) + - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10) ).strftime(settings.GITHUB_TIMESTAMP_FORMAT) with self.assertRaises(github_utils.RunTimeoutError): @@ -103,29 +105,26 @@ def get_response_authorize(_: httpx.Client, request: httpx.Request, **__) -> htt "account": {"login": "VALID_OWNER"}, "access_tokens_url": "https://example.com/ACCESS_TOKEN_URL" }]) - else: - return httpx.Response( - 401, json={"error": "auth app/installations"}, request=request - ) + return httpx.Response( + 401, json={"error": "auth app/installations"}, request=request + ) - elif path == "/installation/repositories": + elif path == "/installation/repositories": # noqa: RET505 if auth == "bearer app access token": return httpx.Response(200, request=request, json={ "repositories": [{ "name": "VALID_REPO" }] }) - else: # pragma: no cover - return httpx.Response( - 401, json={"error": "auth installation/repositories"}, request=request - ) + return httpx.Response( # pragma: no cover + 401, json={"error": "auth installation/repositories"}, request=request + ) - elif request.method == "POST": + elif request.method == "POST": # noqa: RET505 if path == "/ACCESS_TOKEN_URL": if auth == "bearer JWT initial token": return httpx.Response(200, request=request, json={"token": "app access token"}) - else: # pragma: no cover - return httpx.Response(401, json={"error": "auth access_token"}, request=request) + return httpx.Response(401, json={"error": "auth access_token"}, request=request) # pragma: no cover # Reaching this point means something has gone wrong return httpx.Response(500, request=request) # pragma: no cover @@ -138,7 +137,7 @@ class AuthorizeTests(unittest.TestCase): def test_invalid_apps_auth(self): """Test that an exception is raised if authorization was attempted with an invalid token.""" - with mock.patch.object(github_utils, "generate_token", return_value="Invalid token"): + with mock.patch.object(github_utils, "generate_token", return_value="Invalid token"): # noqa: SIM117 with self.assertRaises(httpx.HTTPStatusError) as error: github_utils.authorize("VALID_OWNER", "VALID_REPO") @@ -179,7 +178,11 @@ class ArtifactFetcherTests(unittest.TestCase): run = github_utils.WorkflowRun( name="action_name", head_sha="action_sha", - created_at=datetime.datetime.now().strftime(settings.GITHUB_TIMESTAMP_FORMAT), + created_at=( + datetime.datetime + .now(tz=datetime.timezone.utc) + .strftime(settings.GITHUB_TIMESTAMP_FORMAT) + ), status="completed", conclusion="success", artifacts_url="artifacts_url" @@ -187,7 +190,7 @@ class ArtifactFetcherTests(unittest.TestCase): return httpx.Response( 200, request=request, json={"workflow_runs": [dataclasses.asdict(run)]} ) - elif path == "/artifact_url": + elif path == "/artifact_url": # noqa: RET505 return httpx.Response( 200, request=request, json={"artifacts": [{ "name": "artifact_name", diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index ceb5591b..71611ee9 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -8,8 +8,8 @@ from django.db.utils import IntegrityError from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import Infraction, User -from ..serializers import InfractionSerializer +from pydis_site.apps.api.models import Infraction, User +from pydis_site.apps.api.serializers import InfractionSerializer class UnauthenticatedTests(AuthenticatedAPITestCase): @@ -152,8 +152,8 @@ class InfractionTests(AuthenticatedAPITestCase): def test_filter_after(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) - response = self.client.get(f'{url}?type=superstar&expires_after={target_time.isoformat()}') + target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5) + response = self.client.get(url, {'type': 'superstar', 'expires_after': target_time.isoformat()}) self.assertEqual(response.status_code, 200) infractions = response.json() @@ -161,8 +161,8 @@ class InfractionTests(AuthenticatedAPITestCase): def test_filter_before(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) - response = self.client.get(f'{url}?type=superstar&expires_before={target_time.isoformat()}') + target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5) + response = self.client.get(url, {'type': 'superstar', 'expires_before': target_time.isoformat()}) self.assertEqual(response.status_code, 200) infractions = response.json() @@ -185,11 +185,12 @@ class InfractionTests(AuthenticatedAPITestCase): def test_after_before_before(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=4) - target_time_late = datetime.datetime.utcnow() + datetime.timedelta(hours=6) + target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=4) + target_time_late = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=6) response = self.client.get( - f'{url}?expires_before={target_time_late.isoformat()}' - f'&expires_after={target_time.isoformat()}' + url, + {'expires_before': target_time_late.isoformat(), + 'expires_after': target_time.isoformat()}, ) self.assertEqual(response.status_code, 200) @@ -198,11 +199,12 @@ class InfractionTests(AuthenticatedAPITestCase): def test_after_after_before_invalid(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) - target_time_late = datetime.datetime.utcnow() + datetime.timedelta(hours=9) + target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5) + target_time_late = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=9) response = self.client.get( - f'{url}?expires_before={target_time.isoformat()}' - f'&expires_after={target_time_late.isoformat()}' + url, + {'expires_before': target_time.isoformat(), + 'expires_after': target_time_late.isoformat()}, ) self.assertEqual(response.status_code, 400) @@ -212,8 +214,11 @@ class InfractionTests(AuthenticatedAPITestCase): def test_permanent_after_invalid(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) - response = self.client.get(f'{url}?permanent=true&expires_after={target_time.isoformat()}') + target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5) + response = self.client.get( + url, + {'permanent': 'true', 'expires_after': target_time.isoformat()}, + ) self.assertEqual(response.status_code, 400) errors = list(response.json()) @@ -221,8 +226,11 @@ class InfractionTests(AuthenticatedAPITestCase): def test_permanent_before_invalid(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) - response = self.client.get(f'{url}?permanent=true&expires_before={target_time.isoformat()}') + target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5) + response = self.client.get( + url, + {'permanent': 'true', 'expires_before': target_time.isoformat()}, + ) self.assertEqual(response.status_code, 400) errors = list(response.json()) @@ -230,9 +238,10 @@ class InfractionTests(AuthenticatedAPITestCase): def test_nonpermanent_before(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=6) + target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=6) response = self.client.get( - f'{url}?permanent=false&expires_before={target_time.isoformat()}' + url, + {'permanent': 'false', 'expires_before': target_time.isoformat()}, ) self.assertEqual(response.status_code, 200) @@ -522,39 +531,38 @@ class CreationTests(AuthenticatedAPITestCase): active_infraction_types = ('timeout', 'ban', 'superstar') for infraction_type in active_infraction_types: - with self.subTest(infraction_type=infraction_type): - with transaction.atomic(): - first_active_infraction = { - 'user': self.user.id, - 'actor': self.user.id, - 'type': infraction_type, - 'reason': 'Take me on!', - 'active': True, - 'expires_at': '2019-10-04T12:52:00+00:00' - } + with self.subTest(infraction_type=infraction_type), transaction.atomic(): + first_active_infraction = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': infraction_type, + 'reason': 'Take me on!', + 'active': True, + 'expires_at': '2019-10-04T12:52:00+00:00' + } + + # Post the first active infraction of a type and confirm it's accepted. + first_response = self.client.post(url, data=first_active_infraction) + self.assertEqual(first_response.status_code, 201) - # Post the first active infraction of a type and confirm it's accepted. - first_response = self.client.post(url, data=first_active_infraction) - self.assertEqual(first_response.status_code, 201) - - second_active_infraction = { - 'user': self.user.id, - 'actor': self.user.id, - 'type': infraction_type, - 'reason': 'Take on me!', - 'active': True, - 'expires_at': '2019-10-04T12:52:00+00:00' + second_active_infraction = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': infraction_type, + 'reason': 'Take on me!', + 'active': True, + 'expires_at': '2019-10-04T12:52:00+00:00' + } + second_response = self.client.post(url, data=second_active_infraction) + self.assertEqual(second_response.status_code, 400) + self.assertEqual( + second_response.json(), + { + 'non_field_errors': [ + 'This user already has an active infraction of this type.' + ] } - second_response = self.client.post(url, data=second_active_infraction) - self.assertEqual(second_response.status_code, 400) - self.assertEqual( - second_response.json(), - { - 'non_field_errors': [ - 'This user already has an active infraction of this type.' - ] - } - ) + ) def test_returns_201_for_second_active_infraction_of_different_type(self): """Test if the API accepts a second active infraction of a different type than the first.""" diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index d3341b35..1cca133d 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -118,7 +118,7 @@ class StringDunderMethodTests(SimpleTestCase): OffensiveMessage( id=602951077675139072, channel_id=291284109232308226, - delete_date=dt(3000, 1, 1) + delete_date=dt(3000, 1, 1, tzinfo=timezone.utc) ), OffTopicChannelName(name='bob-the-builders-playground'), Role( @@ -132,7 +132,7 @@ class StringDunderMethodTests(SimpleTestCase): name='shawn', discriminator=555, ), - creation=dt.utcnow() + creation=dt.now(tz=timezone.utc) ), User( id=5, diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py index b3742cdd..ee6b1fbd 100644 --- a/pydis_site/apps/api/tests/test_nominations.py +++ b/pydis_site/apps/api/tests/test_nominations.py @@ -3,7 +3,7 @@ from datetime import datetime as dt, timedelta, timezone from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import Nomination, NominationEntry, User +from pydis_site.apps.api.models import Nomination, NominationEntry, User class CreationTests(AuthenticatedAPITestCase): diff --git a/pydis_site/apps/api/tests/test_off_topic_channel_names.py b/pydis_site/apps/api/tests/test_off_topic_channel_names.py index 34098c92..315f707d 100644 --- a/pydis_site/apps/api/tests/test_off_topic_channel_names.py +++ b/pydis_site/apps/api/tests/test_off_topic_channel_names.py @@ -1,7 +1,7 @@ from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import OffTopicChannelName +from pydis_site.apps.api.models import OffTopicChannelName class UnauthenticatedTests(AuthenticatedAPITestCase): diff --git a/pydis_site/apps/api/tests/test_offensive_message.py b/pydis_site/apps/api/tests/test_offensive_message.py index 3cf95b75..53f9cb48 100644 --- a/pydis_site/apps/api/tests/test_offensive_message.py +++ b/pydis_site/apps/api/tests/test_offensive_message.py @@ -3,13 +3,13 @@ import datetime from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import OffensiveMessage +from pydis_site.apps.api.models import OffensiveMessage class CreationTests(AuthenticatedAPITestCase): def test_accept_valid_data(self): url = reverse('api:bot:offensivemessage-list') - delete_at = datetime.datetime.now() + datetime.timedelta(days=1) + delete_at = datetime.datetime.now() + datetime.timedelta(days=1) # noqa: DTZ005 data = { 'id': '602951077675139072', 'channel_id': '291284109232308226', @@ -32,7 +32,7 @@ class CreationTests(AuthenticatedAPITestCase): def test_returns_400_on_non_future_date(self): url = reverse('api:bot:offensivemessage-list') - delete_at = datetime.datetime.now() - datetime.timedelta(days=1) + delete_at = datetime.datetime.now() - datetime.timedelta(days=1) # noqa: DTZ005 data = { 'id': '602951077675139072', 'channel_id': '291284109232308226', @@ -46,7 +46,7 @@ class CreationTests(AuthenticatedAPITestCase): def test_returns_400_on_negative_id_or_channel_id(self): url = reverse('api:bot:offensivemessage-list') - delete_at = datetime.datetime.now() + datetime.timedelta(days=1) + delete_at = datetime.datetime.now() + datetime.timedelta(days=1) # noqa: DTZ005 data = { 'id': '602951077675139072', 'channel_id': '291284109232308226', @@ -72,7 +72,7 @@ class CreationTests(AuthenticatedAPITestCase): class ListTests(AuthenticatedAPITestCase): @classmethod def setUpTestData(cls): - delete_at = datetime.datetime.now() + datetime.timedelta(days=1) + delete_at = datetime.datetime.now() + datetime.timedelta(days=1) # noqa: DTZ005 aware_delete_at = delete_at.replace(tzinfo=datetime.timezone.utc) cls.messages = [ diff --git a/pydis_site/apps/api/tests/test_reminders.py b/pydis_site/apps/api/tests/test_reminders.py index e17569f0..9bb5fe4d 100644 --- a/pydis_site/apps/api/tests/test_reminders.py +++ b/pydis_site/apps/api/tests/test_reminders.py @@ -4,7 +4,7 @@ from django.forms.models import model_to_dict from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import Reminder, User +from pydis_site.apps.api.models import Reminder, User class UnauthedReminderAPITests(AuthenticatedAPITestCase): @@ -59,7 +59,7 @@ class ReminderCreationTests(AuthenticatedAPITestCase): data = { 'author': self.author.id, 'content': 'Remember to...wait what was it again?', - 'expiration': datetime.utcnow().isoformat(), + 'expiration': datetime.now(tz=timezone.utc).isoformat(), 'jump_url': "https://www.google.com", 'channel_id': 123, 'mentions': [8888, 9999], diff --git a/pydis_site/apps/api/tests/test_roles.py b/pydis_site/apps/api/tests/test_roles.py index 73c80c77..d3031990 100644 --- a/pydis_site/apps/api/tests/test_roles.py +++ b/pydis_site/apps/api/tests/test_roles.py @@ -1,7 +1,7 @@ from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import Role, User +from pydis_site.apps.api.models import Role, User class CreationTests(AuthenticatedAPITestCase): diff --git a/pydis_site/apps/api/tests/test_rules.py b/pydis_site/apps/api/tests/test_rules.py index 3ee2d4e0..662fb8e9 100644 --- a/pydis_site/apps/api/tests/test_rules.py +++ b/pydis_site/apps/api/tests/test_rules.py @@ -5,7 +5,7 @@ from pathlib import Path from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..views import RulesView +from pydis_site.apps.api.views import RulesView class RuleAPITests(AuthenticatedAPITestCase): diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py index d86e80bb..cff4a825 100644 --- a/pydis_site/apps/api/tests/test_users.py +++ b/pydis_site/apps/api/tests/test_users.py @@ -4,9 +4,9 @@ from unittest.mock import Mock, patch from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import Infraction, Role, User -from ..models.bot.metricity import NotFoundError -from ..viewsets.bot.user import UserListPagination +from pydis_site.apps.api.models import Infraction, Role, User +from pydis_site.apps.api.models.bot.metricity import NotFoundError +from pydis_site.apps.api.viewsets.bot.user import UserListPagination class UnauthedUserAPITests(AuthenticatedAPITestCase): @@ -469,18 +469,17 @@ class UserMetricityTests(AuthenticatedAPITestCase): with self.subTest( voice_infractions=case['voice_infractions'], voice_gate_blocked=case['voice_gate_blocked'] - ): - with patch("pydis_site.apps.api.viewsets.bot.user.Infraction.objects.filter") as p: - p.return_value = case['voice_infractions'] - - url = reverse('api:bot:user-metricity-data', args=[0]) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.json()["voice_gate_blocked"], - case["voice_gate_blocked"] - ) + ), patch("pydis_site.apps.api.viewsets.bot.user.Infraction.objects.filter") as p: + p.return_value = case['voice_infractions'] + + url = reverse('api:bot:user-metricity-data', args=[0]) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json()["voice_gate_blocked"], + case["voice_gate_blocked"] + ) def test_metricity_review_data(self): # Given diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py index 8c46fcbc..a7ec6e38 100644 --- a/pydis_site/apps/api/tests/test_validators.py +++ b/pydis_site/apps/api/tests/test_validators.py @@ -3,8 +3,8 @@ from datetime import datetime, timezone from django.core.exceptions import ValidationError from django.test import TestCase -from ..models.bot.bot_setting import validate_bot_setting_name -from ..models.bot.offensive_message import future_date_validator +from pydis_site.apps.api.models.bot.bot_setting import validate_bot_setting_name +from pydis_site.apps.api.models.bot.offensive_message import future_date_validator REQUIRED_KEYS = ( diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index b1b7dc0f..32f41667 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -93,7 +93,7 @@ class RulesView(APIView): """ if target == 'html': return f'{description}' - elif target == 'md': + elif target == 'md': # noqa: RET505 return f'[{description}]({link})' else: raise ValueError( @@ -101,7 +101,7 @@ class RulesView(APIView): ) # `format` here is the result format, we have a link format here instead. - def get(self, request, format=None): # noqa: D102,ANN001,ANN201 + def get(self, request, format=None): # noqa: ANN001, ANN201 """ Returns a list of our community rules coupled with their keywords. diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py index d6c2d18c..9c9e8338 100644 --- a/pydis_site/apps/api/viewsets/bot/filters.py +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -1,10 +1,10 @@ from rest_framework.viewsets import ModelViewSet -from pydis_site.apps.api.models.bot.filters import ( # noqa: I101 - Preserving the filter order +from pydis_site.apps.api.models.bot.filters import ( # - Preserving the filter order FilterList, Filter ) -from pydis_site.apps.api.serializers import ( # noqa: I101 - Preserving the filter order +from pydis_site.apps.api.serializers import ( # - Preserving the filter order FilterListSerializer, FilterSerializer, ) diff --git a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py index d0519e86..1774004c 100644 --- a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py +++ b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py @@ -85,10 +85,9 @@ class OffTopicChannelNameViewSet(ModelViewSet): serializer.save() return Response(create_data, status=HTTP_201_CREATED) - else: - raise ParseError(detail={ - 'name': ["This query parameter is required."] - }) + raise ParseError(detail={ + 'name': ["This query parameter is required."] + }) def list(self, request: Request, *args, **kwargs) -> Response: """ diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py index db73a83c..88fa3415 100644 --- a/pydis_site/apps/api/viewsets/bot/user.py +++ b/pydis_site/apps/api/viewsets/bot/user.py @@ -1,4 +1,3 @@ -import typing from collections import OrderedDict from django.db.models import Q @@ -24,14 +23,14 @@ class UserListPagination(PageNumberPagination): page_size = 2500 page_size_query_param = "page_size" - def get_next_page_number(self) -> typing.Optional[int]: + def get_next_page_number(self) -> int | None: """Get the next page number.""" if not self.page.has_next(): return None page_number = self.page.next_page_number() return page_number - def get_previous_page_number(self) -> typing.Optional[int]: + def get_previous_page_number(self) -> int | None: """Get the previous page number.""" if not self.page.has_previous(): return None diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py index 1a20d775..7c49902f 100644 --- a/pydis_site/apps/content/models/tag.py +++ b/pydis_site/apps/content/models/tag.py @@ -30,8 +30,7 @@ class Commit(models.Model): def lines(self) -> collections.abc.Iterable[str]: """Return each line in the commit message.""" - for line in self.message.split("\n"): - yield line + yield from self.message.split("\n") def format_authors(self) -> collections.abc.Iterable[str]: """Return a nice representation of the author(s)' name and email.""" diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md index f6f8a5f2..b634f513 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md @@ -4,7 +4,7 @@ description: A guide for linting and setting up pre-commit. --- Your commit will be rejected by the build server if it fails to lint. -On most of our projects, we use `flake8` and `pre-commit` to ensure that the code style is consistent across the code base. +On most of our projects, we use `ruff` and `pre-commit` to ensure that the code style is consistent across the code base. `pre-commit` is a powerful tool that helps you automatically lint before you commit. If the linter complains, the commit is aborted so that you can fix the linting errors before committing again. diff --git a/pydis_site/apps/content/tests/helpers.py b/pydis_site/apps/content/tests/helpers.py index fad91050..0e7562e8 100644 --- a/pydis_site/apps/content/tests/helpers.py +++ b/pydis_site/apps/content/tests/helpers.py @@ -62,19 +62,19 @@ class MockPagesTestCase(TestCase): ├── not_a_page.md ├── tmp.md ├── tmp - |   ├── _info.yml - |   └── category - |    ├── _info.yml - |      └── subcategory_without_info + | ├── _info.yml + | └── category + | ├── _info.yml + | └── subcategory_without_info └── category -    ├── _info.yml -    ├── with_metadata.md -    └── subcategory -    ├── with_metadata.md -       └── without_metadata.md + ├── _info.yml + ├── with_metadata.md + └── subcategory + ├── with_metadata.md + └── without_metadata.md """ - def setUp(self): + def setUp(self) -> None: """Create the fake filesystem.""" Path(f"{BASE_PATH}/_info.yml").write_text(CATEGORY_INFO) Path(f"{BASE_PATH}/root.md").write_text(MARKDOWN_WITH_METADATA) diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py index a7695a27..baae154d 100644 --- a/pydis_site/apps/content/urls.py +++ b/pydis_site/apps/content/urls.py @@ -8,7 +8,7 @@ from . import utils, views app_name = "content" -def __get_all_files(root: Path, folder: typing.Optional[Path] = None) -> list[str]: +def __get_all_files(root: Path, folder: Path | None = None) -> list[str]: """Find all folders and markdown files recursively starting from `root`.""" if not folder: folder = root diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index c12893ef..347640dd 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -151,8 +151,11 @@ def set_tag_commit(tag: Tag) -> None: commit = data["commit"] author, committer = commit["author"], commit["committer"] - date = datetime.datetime.strptime(committer["date"], settings.GITHUB_TIMESTAMP_FORMAT) - date = date.replace(tzinfo=datetime.timezone.utc) + date = ( + datetime.datetime + .strptime(committer["date"], settings.GITHUB_TIMESTAMP_FORMAT) + .replace(tzinfo=datetime.timezone.utc) + ) if author["email"] == committer["email"]: authors = [author] @@ -212,9 +215,8 @@ def get_tags() -> list[Tag]: record_tags(tags) return tags - else: - # Get tags from database - return list(Tag.objects.all()) + + return list(Tag.objects.all()) def get_tag(path: str, *, skip_sync: bool = False) -> Tag | list[Tag]: @@ -242,13 +244,13 @@ def get_tag(path: str, *, skip_sync: bool = False) -> Tag | list[Tag]: if tag.last_commit is None and not skip_sync: set_tag_commit(tag) return tag - elif tag.group == name and group is None: + elif tag.group == name and group is None: # noqa: RET505 matches.append(tag) if matches: return matches - raise Tag.DoesNotExist() + raise Tag.DoesNotExist def get_tag_category(tags: list[Tag] | None = None, *, collapse_groups: bool) -> dict[str, dict]: diff --git a/pydis_site/apps/content/views/tags.py b/pydis_site/apps/content/views/tags.py index 4f4bb5a2..8d3e3321 100644 --- a/pydis_site/apps/content/views/tags.py +++ b/pydis_site/apps/content/views/tags.py @@ -1,5 +1,4 @@ import re -import typing import frontmatter import markdown @@ -22,7 +21,7 @@ COMMAND_REGEX = re.compile(r"`*!tags? (?P[\w-]+)(?P [\w-]+)?`*") class TagView(TemplateView): """Handles tag pages.""" - tag: typing.Union[Tag, list[Tag]] + tag: Tag | list[Tag] is_group: bool def setup(self, *args, **kwargs) -> None: diff --git a/pydis_site/apps/events/urls.py b/pydis_site/apps/events/urls.py index 7ea65a31..6121d264 100644 --- a/pydis_site/apps/events/urls.py +++ b/pydis_site/apps/events/urls.py @@ -8,7 +8,7 @@ from pydis_site.apps.events.views import IndexView, PageView app_name = "events" -def __get_all_files(root: Path, folder: typing.Optional[Path] = None) -> list[str]: +def __get_all_files(root: Path, folder: Path | None = None) -> list[str]: """Find all folders and HTML files recursively starting from `root`.""" if not folder: folder = root diff --git a/pydis_site/apps/events/views/page.py b/pydis_site/apps/events/views/page.py index 1622ad70..adf9e952 100644 --- a/pydis_site/apps/events/views/page.py +++ b/pydis_site/apps/events/views/page.py @@ -1,4 +1,3 @@ -from typing import List from django.conf import settings from django.http import Http404 @@ -8,7 +7,7 @@ from django.views.generic import TemplateView class PageView(TemplateView): """Handles event pages showing.""" - def get_template_names(self) -> List[str]: + def get_template_names(self) -> list[str]: """Get specific template names.""" path: str = self.kwargs['path'] page_path = settings.EVENTS_PAGES_PATH / path diff --git a/pydis_site/apps/home/tests/test_repodata_helpers.py b/pydis_site/apps/home/tests/test_repodata_helpers.py index a963f733..acf4a817 100644 --- a/pydis_site/apps/home/tests/test_repodata_helpers.py +++ b/pydis_site/apps/home/tests/test_repodata_helpers.py @@ -22,7 +22,7 @@ def mocked_requests_get(*args, **kwargs) -> "MockResponse": # noqa: F821 if args[0] == HomeView.github_api: json_path = Path(__file__).resolve().parent / "mock_github_api_response.json" - with open(json_path, 'r') as json_file: + with open(json_path) as json_file: mock_data = json.load(json_file) return MockResponse(mock_data, 200) diff --git a/pydis_site/apps/home/views.py b/pydis_site/apps/home/views.py index 8a165682..bfa9e02d 100644 --- a/pydis_site/apps/home/views.py +++ b/pydis_site/apps/home/views.py @@ -1,5 +1,4 @@ import logging -from typing import Dict, List import httpx from django.core.handlers.wsgi import WSGIRequest @@ -45,7 +44,7 @@ class HomeView(View): else: self.headers = {} - def _get_api_data(self) -> Dict[str, Dict[str, str]]: + def _get_api_data(self) -> dict[str, dict[str, str]]: """ Call the GitHub API and get information about our repos. @@ -54,7 +53,7 @@ class HomeView(View): repo_dict = {} try: # Fetch the data from the GitHub API - api_data: List[dict] = httpx.get( + api_data: list[dict] = httpx.get( self.github_api, headers=self.headers, timeout=settings.TIMEOUT_PERIOD @@ -89,7 +88,7 @@ class HomeView(View): return repo_dict - def _get_repo_data(self) -> List[RepositoryMetadata]: + def _get_repo_data(self) -> list[RepositoryMetadata]: """Build a list of RepositoryMetadata objects that we can use to populate the front page.""" # First off, load the timestamp of the least recently updated entry. if settings.STATIC_BUILD: @@ -121,8 +120,7 @@ class HomeView(View): if settings.STATIC_BUILD: return data - else: - return RepositoryMetadata.objects.bulk_create(data) + return RepositoryMetadata.objects.bulk_create(data) # If the data is stale, we should refresh it. if (timezone.now() - last_update).seconds > self.repository_cache_ttl: @@ -149,8 +147,7 @@ class HomeView(View): return database_repositories # Otherwise, if the data is fresher than 2 minutes old, we should just return it. - else: - return RepositoryMetadata.objects.all() + return RepositoryMetadata.objects.all() def get(self, request: WSGIRequest) -> HttpResponse: """Collect repo data and render the homepage view.""" diff --git a/pydis_site/apps/redirect/urls.py b/pydis_site/apps/redirect/urls.py index 067cccc3..a221ea12 100644 --- a/pydis_site/apps/redirect/urls.py +++ b/pydis_site/apps/redirect/urls.py @@ -83,22 +83,21 @@ def map_redirect(name: str, data: Redirect) -> list[URLPattern]: return paths + redirect_path_name = "pages" if new_app_name == "content" else new_app_name + if len(data.redirect_arguments) > 0: + redirect_arg = data.redirect_arguments[0] else: - redirect_path_name = "pages" if new_app_name == "content" else new_app_name - if len(data.redirect_arguments) > 0: - redirect_arg = data.redirect_arguments[0] - else: - redirect_arg = "resources/" - new_redirect = f"/{redirect_path_name}/{redirect_arg}" + redirect_arg = "resources/" + new_redirect = f"/{redirect_path_name}/{redirect_arg}" - if new_redirect == "/resources/resources/": - new_redirect = "/resources/" + if new_redirect == "/resources/resources/": + new_redirect = "/resources/" - return [distill_path( - data.original_path, - lambda *args: HttpResponse(REDIRECT_TEMPLATE.format(url=new_redirect)), - name=name, - )] + return [distill_path( + data.original_path, + lambda *args: HttpResponse(REDIRECT_TEMPLATE.format(url=new_redirect)), + name=name, + )] urlpatterns = [] diff --git a/pydis_site/apps/redirect/views.py b/pydis_site/apps/redirect/views.py index 21180cdf..374daf2b 100644 --- a/pydis_site/apps/redirect/views.py +++ b/pydis_site/apps/redirect/views.py @@ -1,4 +1,3 @@ -import typing as t from django.views.generic import RedirectView @@ -15,7 +14,7 @@ class CustomRedirectView(RedirectView): """Overwrites original as_view to add static args.""" return super().as_view(**initkwargs) - def get_redirect_url(self, *args, **kwargs) -> t.Optional[str]: + def get_redirect_url(self, *args, **kwargs) -> str | None: """Extends default behaviour to use static args.""" args = self.static_args + args + tuple(kwargs.values()) if self.prefix_redirect: diff --git a/pydis_site/apps/resources/views.py b/pydis_site/apps/resources/views.py index 2375f722..a2cd8d0c 100644 --- a/pydis_site/apps/resources/views.py +++ b/pydis_site/apps/resources/views.py @@ -1,5 +1,4 @@ import json -import typing as t from pathlib import Path import yaml @@ -22,7 +21,7 @@ class ResourceView(View): """Sort a tuple by its key alphabetically, disregarding 'the' as a prefix.""" name, resource = tuple_ name = name.casefold() - if name.startswith("the ") or name.startswith("the_"): + if name.startswith(("the ", "the_")): return name[4:] return name @@ -48,7 +47,7 @@ class ResourceView(View): } for resource_name, resource in self.resources.items(): css_classes = [] - for tag_type in resource_tags.keys(): + for tag_type in resource_tags: # Store the tags into `resource_tags` tags = resource.get("tags", {}).get(tag_type, []) for tag in tags: @@ -102,7 +101,7 @@ class ResourceView(View): "difficulty": [to_kebabcase(tier) for tier in self.filters["Difficulty"]["filters"]], } - def get(self, request: WSGIRequest, resource_type: t.Optional[str] = None) -> HttpResponse: + def get(self, request: WSGIRequest, resource_type: str | None = None) -> HttpResponse: """List out all the resources, and any filtering options from the URL.""" # Add type filtering if the request is made to somewhere like /resources/video. # We also convert all spaces to dashes, so they'll correspond with the filters. diff --git a/pydis_site/apps/staff/templatetags/deletedmessage_filters.py b/pydis_site/apps/staff/templatetags/deletedmessage_filters.py index 9d8f1819..c6638a3b 100644 --- a/pydis_site/apps/staff/templatetags/deletedmessage_filters.py +++ b/pydis_site/apps/staff/templatetags/deletedmessage_filters.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Union from django import template @@ -7,7 +6,7 @@ register = template.Library() @register.filter -def hex_colour(colour: Union[str, int]) -> str: +def hex_colour(colour: str | int) -> str: """ Converts the given representation of a colour to its RGB hex string. diff --git a/pydis_site/apps/staff/tests/test_deletedmessage_filters.py b/pydis_site/apps/staff/tests/test_deletedmessage_filters.py index 31215784..5e49f103 100644 --- a/pydis_site/apps/staff/tests/test_deletedmessage_filters.py +++ b/pydis_site/apps/staff/tests/test_deletedmessage_filters.py @@ -3,7 +3,7 @@ import enum from django.test import TestCase from django.utils import timezone -from ..templatetags import deletedmessage_filters +from pydis_site.apps.staff.templatetags import deletedmessage_filters class Colour(enum.IntEnum): diff --git a/pyproject.toml b/pyproject.toml index 40461f0b..9019efb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,18 +29,9 @@ whitenoise = "6.4.0" [tool.poetry.group.dev.dependencies] python-dotenv = "1.0.0" taskipy = "1.10.4" +ruff = "^0.0.265" [tool.poetry.group.lint.dependencies] -flake8 = "6.0.0" -flake8-annotations = "3.0.1" -flake8-bandit = "4.1.1" -flake8-bugbear = "23.5.9" -flake8-docstrings = "1.7.0" -flake8-import-order = "0.18.2" -flake8-tidy-imports = "4.8.0" -flake8-string-format = "0.3.0" -flake8-todo = "0.7" -pep8-naming = "0.13.3" pre-commit = "3.3.1" [tool.poetry.group.test.dependencies] @@ -50,6 +41,34 @@ coverage = "7.2.5" requires = ["poetry-core>=1.2.0"] build-backend = "poetry.core.masonry.api" +[tool.ruff] +target-version = "py310" +extend-exclude = [".cache"] +ignore = [ + "ANN002", "ANN003", "ANN101", "ANN102", "ANN204", "ANN206", "ANN401", + "B904", + "C401", "C408", + "D100", "D104", "D105", "D107", "D203", "D212", "D214", "D215", "D301", + "D400", "D401", "D402", "D404", "D405", "D406", "D407", "D408", "D409", "D410", "D411", "D412", "D413", "D414", "D416", "D417", + "E731", + "RET504", + "RUF005", + "S311", + "SIM102", "SIM108", +] +line-length = 120 +select = ["ANN", "B", "C4", "D", "DTZ", "E", "F", "ISC", "INT", "N", "PGH", "PIE", "RET", "RSE", "RUF", "S", "SIM", "T20", "TID", "UP", "W"] + +[tool.ruff.per-file-ignores] +"pydis_site/apps/**/migrations/*.py" = ["ALL"] +"manage.py" = ["T201"] +"pydis_site/apps/api/tests/base.py" = ["S106"] +"pydis_site/apps/**/tests/test_*.py" = ["ANN", "D"] +"static-builds/netlify_build.py" = ["T201"] +"pydis_site/apps/api/tests/test_off_topic_channel_names.py" = ["RUF001"] +"gunicorn.conf.py" = ["ANN", "D"] +"pydis_site/apps/api/models/bot/off_topic_channel_name.py" = ["RUF001"] + [tool.taskipy.tasks] start = "python manage.py run --debug" makemigrations = "python manage.py makemigrations" diff --git a/static-builds/netlify_build.py b/static-builds/netlify_build.py index 36520c28..2d311a11 100644 --- a/static-builds/netlify_build.py +++ b/static-builds/netlify_build.py @@ -14,15 +14,15 @@ from pathlib import Path from urllib import parse import httpx +import contextlib def raise_response(response: httpx.Response) -> None: """Raise an exception from a response if necessary.""" if response.status_code // 100 != 2: - try: + with contextlib.suppress(json.JSONDecodeError): print(response.json()) - except json.JSONDecodeError: - pass + response.raise_for_status() -- cgit v1.2.3 From 5eada1e9801c158b9d90b673ee5fd504b4806eeb Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 10 May 2023 13:37:39 +0200 Subject: Add Django-specific rules for ruff --- pydis_site/apps/api/models/bot/documentation_link.py | 8 ++++---- pydis_site/apps/api/models/bot/filters.py | 6 +++--- pydis_site/apps/api/models/bot/infraction.py | 18 +++++++++--------- pydis_site/apps/api/models/bot/message.py | 10 +++++----- .../apps/api/models/bot/message_deletion_context.py | 10 +++++----- pydis_site/apps/api/models/bot/nomination.py | 10 +++++----- pydis_site/apps/api/models/bot/role.py | 10 +++++----- pyproject.toml | 3 ++- 8 files changed, 38 insertions(+), 37 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/bot/documentation_link.py b/pydis_site/apps/api/models/bot/documentation_link.py index 9941907c..7f3b4ca5 100644 --- a/pydis_site/apps/api/models/bot/documentation_link.py +++ b/pydis_site/apps/api/models/bot/documentation_link.py @@ -37,11 +37,11 @@ class DocumentationLink(ModelReprMixin, models.Model): help_text="The URL at which the Sphinx inventory is available for this package." ) - def __str__(self): - """Returns the package and URL for the current documentation link, for display purposes.""" - return f"{self.package} - {self.base_url}" - class Meta: """Defines the meta options for the documentation link model.""" ordering = ['package'] + + def __str__(self): + """Returns the package and URL for the current documentation link, for display purposes.""" + return f"{self.package} - {self.base_url}" diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py index 620031dc..6d5188e4 100644 --- a/pydis_site/apps/api/models/bot/filters.py +++ b/pydis_site/apps/api/models/bot/filters.py @@ -231,14 +231,14 @@ class FilterBase(ModelTimestampMixin, ModelReprMixin, models.Model): null=True ) - def __str__(self) -> str: - return f"Filter {self.content!r}" - class Meta: """Metaclass for FilterBase to make it abstract model.""" abstract = True + def __str__(self) -> str: + return f"Filter {self.content!r}" + class Filter(FilterBase): """ diff --git a/pydis_site/apps/api/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py index 381b5b9d..b304c6d4 100644 --- a/pydis_site/apps/api/models/bot/infraction.py +++ b/pydis_site/apps/api/models/bot/infraction.py @@ -78,15 +78,6 @@ class Infraction(ModelReprMixin, models.Model): ) ) - def __str__(self): - """Returns some info on the current infraction, for display purposes.""" - s = f"#{self.id}: {self.type} on {self.user_id}" - if self.expires_at: - s += f" until {self.expires_at}" - if self.hidden: - s += " (hidden)" - return s - class Meta: """Defines the meta options for the infraction model.""" @@ -98,3 +89,12 @@ class Infraction(ModelReprMixin, models.Model): name="unique_active_infraction_per_type_per_user" ), ) + + def __str__(self): + """Returns some info on the current infraction, for display purposes.""" + s = f"#{self.id}: {self.type} on {self.user_id}" + if self.expires_at: + s += f" until {self.expires_at}" + if self.hidden: + s += " (hidden)" + return s diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index d8147cd4..fb3c47fc 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -58,6 +58,11 @@ class Message(ModelReprMixin, models.Model): help_text="Attachments attached to this message." ) + class Meta: + """Metadata provided for Django's ORM.""" + + abstract = True + @property def timestamp(self) -> datetime.datetime: """Attribute that represents the message timestamp as derived from the snowflake id.""" @@ -65,8 +70,3 @@ class Message(ModelReprMixin, models.Model): ((self.id >> 22) + 1420070400000) / 1000, tz=datetime.timezone.utc, ) - - class Meta: - """Metadata provided for Django's ORM.""" - - abstract = True diff --git a/pydis_site/apps/api/models/bot/message_deletion_context.py b/pydis_site/apps/api/models/bot/message_deletion_context.py index 25741266..207bc4bc 100644 --- a/pydis_site/apps/api/models/bot/message_deletion_context.py +++ b/pydis_site/apps/api/models/bot/message_deletion_context.py @@ -30,12 +30,12 @@ class MessageDeletionContext(ModelReprMixin, models.Model): help_text="When this deletion took place." ) - @property - def log_url(self) -> str: - """Create the url for the deleted message logs.""" - return reverse('staff:logs', args=(self.id,)) - class Meta: """Set the ordering for list views to newest first.""" ordering = ("-creation",) + + @property + def log_url(self) -> str: + """Create the url for the deleted message logs.""" + return reverse('staff:logs', args=(self.id,)) diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py index 58e70a83..2f8e305c 100644 --- a/pydis_site/apps/api/models/bot/nomination.py +++ b/pydis_site/apps/api/models/bot/nomination.py @@ -40,16 +40,16 @@ class Nomination(ModelReprMixin, models.Model): null=True, ) - def __str__(self): - """Representation that makes the target and state of the nomination immediately evident.""" - status = "active" if self.active else "ended" - return f"Nomination of {self.user} ({status})" - class Meta: """Set the ordering of nominations to most recent first.""" ordering = ("-inserted_at",) + def __str__(self): + """Representation that makes the target and state of the nomination immediately evident.""" + status = "active" if self.active else "ended" + return f"Nomination of {self.user} ({status})" + class NominationEntry(ModelReprMixin, models.Model): """A nomination entry created by a single staff member.""" diff --git a/pydis_site/apps/api/models/bot/role.py b/pydis_site/apps/api/models/bot/role.py index 733a8e08..e37f3ccd 100644 --- a/pydis_site/apps/api/models/bot/role.py +++ b/pydis_site/apps/api/models/bot/role.py @@ -51,6 +51,11 @@ class Role(ModelReprMixin, models.Model): help_text="The position of the role in the role hierarchy of the Discord Guild." ) + class Meta: + """Set role ordering from highest to lowest position.""" + + ordering = ("-position",) + def __str__(self) -> str: """Returns the name of the current role, for display purposes.""" return self.name @@ -62,8 +67,3 @@ class Role(ModelReprMixin, models.Model): def __le__(self, other: Role) -> bool: """Compares the roles based on their position in the role hierarchy of the guild.""" return self.position <= other.position - - class Meta: - """Set role ordering from highest to lowest position.""" - - ordering = ("-position",) diff --git a/pyproject.toml b/pyproject.toml index 9019efb0..fb82aa2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,13 +51,14 @@ ignore = [ "D100", "D104", "D105", "D107", "D203", "D212", "D214", "D215", "D301", "D400", "D401", "D402", "D404", "D405", "D406", "D407", "D408", "D409", "D410", "D411", "D412", "D413", "D414", "D416", "D417", "E731", + "DJ001", "DJ008", "RET504", "RUF005", "S311", "SIM102", "SIM108", ] line-length = 120 -select = ["ANN", "B", "C4", "D", "DTZ", "E", "F", "ISC", "INT", "N", "PGH", "PIE", "RET", "RSE", "RUF", "S", "SIM", "T20", "TID", "UP", "W"] +select = ["ANN", "B", "C4", "D", "DJ", "DTZ", "E", "F", "ISC", "INT", "N", "PGH", "PIE", "RET", "RSE", "RUF", "S", "SIM", "T20", "TID", "UP", "W"] [tool.ruff.per-file-ignores] "pydis_site/apps/**/migrations/*.py" = ["ALL"] -- cgit v1.2.3 From 3cc0dc390bb2635a1192b669f543410d8fd1e59a Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 10 May 2023 13:54:15 +0200 Subject: Do not duplicate "Edit on GitHub" link for tags Closes #963. --- pydis_site/apps/content/tests/test_views.py | 8 ++++++++ pydis_site/templates/content/base.html | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py index e4f898ef..cfc580c0 100644 --- a/pydis_site/apps/content/tests/test_views.py +++ b/pydis_site/apps/content/tests/test_views.py @@ -387,6 +387,14 @@ class TagViewTests(django.test.TestCase): response.context.get("page") ) + def test_tags_have_no_edit_on_github_link(self): + """Tags should not have the standard edit on GitHub link.""" + # The standard "Edit on GitHub" link should not be displayed on tags + # because they have their own GitHub icon that links there. + Tag.objects.create(name="example", body="Joe William Banks", last_commit=self.commit) + response = self.client.get("/pages/tags/example/") + self.assertNotContains(response, "Edit on GitHub") + def test_tag_root_page(self): """Test the root tag page which lists all tags.""" Tag.objects.create(name="tag-1", last_commit=self.commit) diff --git a/pydis_site/templates/content/base.html b/pydis_site/templates/content/base.html index b04c7efa..bda6d954 100644 --- a/pydis_site/templates/content/base.html +++ b/pydis_site/templates/content/base.html @@ -37,7 +37,7 @@ {% include "content/dropdown.html" %} {% endif %} {# Edit on GitHub for content articles #} - {% if page %} + {% if page and not tag %}
      -- cgit v1.2.3 From 625f57488c9856b5dce0d16bb192119dd2cb3a1c Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 12 May 2023 10:29:34 +0200 Subject: Fix psycopg3 compatibility in metricity --- pydis_site/apps/api/models/bot/metricity.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/models/bot/metricity.py b/pydis_site/apps/api/models/bot/metricity.py index a55f5e5b..f1277b21 100644 --- a/pydis_site/apps/api/models/bot/metricity.py +++ b/pydis_site/apps/api/models/bot/metricity.py @@ -3,10 +3,11 @@ from django.db import connections BLOCK_INTERVAL = 10 * 60 # 10 minute blocks -EXCLUDE_CHANNELS = ( +# This needs to be a list due to psycopg3 type adaptions. +EXCLUDE_CHANNELS = [ "267659945086812160", # Bot commands "607247579608121354" # SeasonalBot commands -) +] class NotFoundError(Exception): @@ -48,7 +49,7 @@ class Metricity: WHERE author_id = '%s' AND NOT is_deleted - AND channel_id NOT IN %s + AND channel_id != ANY(%s) """, [user_id, EXCLUDE_CHANNELS] ) @@ -76,7 +77,7 @@ class Metricity: WHERE author_id='%s' AND NOT is_deleted - AND channel_id NOT IN %s + AND channel_id != ANY(%s) GROUP BY interval ) block_query; """, @@ -144,13 +145,13 @@ class Metricity: author_id, COUNT(*) FROM messages WHERE - author_id IN %s + author_id = ANY(%s) AND NOT is_deleted - AND channel_id NOT IN %s + AND channel_id != ANY(%s) AND created_at > now() - interval '%s days' GROUP BY author_id """, - [tuple(user_ids), EXCLUDE_CHANNELS, days] + [user_ids, EXCLUDE_CHANNELS, days] ) values = self.cursor.fetchall() -- cgit v1.2.3 From bc4fb26606e1e7821525dbb617f35f96cf3b6be3 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 13 May 2023 14:28:44 +0100 Subject: Specify file encoding so tests pass on windows This file has non-cp1252 characters, which is the default on windows, so fails to read the file. --- pydis_site/apps/api/tests/test_rules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_rules.py b/pydis_site/apps/api/tests/test_rules.py index 662fb8e9..14412b90 100644 --- a/pydis_site/apps/api/tests/test_rules.py +++ b/pydis_site/apps/api/tests/test_rules.py @@ -56,7 +56,7 @@ class RuleCorrectnessTests(AuthenticatedAPITestCase): ) markdown_rules = [] - for line in markdown_rules_path.read_text().splitlines(): + for line in markdown_rules_path.read_text(encoding="utf8").splitlines(): matches = self.markdown_rule_re.match(line) if matches is not None: markdown_rules.append(matches.group(1)) -- cgit v1.2.3 From 509449c1a7d4230c3a4b1e335b681fbecf0313c0 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 13 May 2023 14:31:39 +0100 Subject: Use the new datetime.UTC alias over datetime.timezone.utc --- pydis_site/apps/api/github_utils.py | 6 ++-- pydis_site/apps/api/models/bot/message.py | 2 +- .../apps/api/models/bot/offensive_message.py | 2 +- pydis_site/apps/api/tests/test_deleted_messages.py | 8 ++--- pydis_site/apps/api/tests/test_github_utils.py | 8 ++--- pydis_site/apps/api/tests/test_infractions.py | 40 +++++++++++----------- pydis_site/apps/api/tests/test_models.py | 14 ++++---- pydis_site/apps/api/tests/test_nominations.py | 6 ++-- .../apps/api/tests/test_offensive_message.py | 8 ++--- pydis_site/apps/api/tests/test_reminders.py | 14 ++++---- pydis_site/apps/api/tests/test_validators.py | 6 ++-- pydis_site/apps/api/viewsets/bot/infraction.py | 4 +-- pydis_site/apps/content/tests/test_utils.py | 2 +- pydis_site/apps/content/utils.py | 4 +-- 14 files changed, 62 insertions(+), 62 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py index af659195..b1a7d07d 100644 --- a/pydis_site/apps/api/github_utils.py +++ b/pydis_site/apps/api/github_utils.py @@ -82,7 +82,7 @@ def generate_token() -> str: Refer to: https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app """ - now = datetime.datetime.now(tz=datetime.timezone.utc) + now = datetime.datetime.now(tz=datetime.UTC) return jwt.encode( { "iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()), # Issued at @@ -148,9 +148,9 @@ def check_run_status(run: WorkflowRun) -> str: created_at = ( datetime.datetime .strptime(run.created_at, settings.GITHUB_TIMESTAMP_FORMAT) - .replace(tzinfo=datetime.timezone.utc) + .replace(tzinfo=datetime.UTC) ) - run_time = datetime.datetime.now(tz=datetime.timezone.utc) - created_at + run_time = datetime.datetime.now(tz=datetime.UTC) - created_at if run.status != "completed": if run_time <= MAX_RUN_TIME: diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index fb3c47fc..f90f5dd0 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -68,5 +68,5 @@ class Message(ModelReprMixin, models.Model): """Attribute that represents the message timestamp as derived from the snowflake id.""" return datetime.datetime.fromtimestamp( ((self.id >> 22) + 1420070400000) / 1000, - tz=datetime.timezone.utc, + tz=datetime.UTC, ) diff --git a/pydis_site/apps/api/models/bot/offensive_message.py b/pydis_site/apps/api/models/bot/offensive_message.py index 74dab59b..41805a16 100644 --- a/pydis_site/apps/api/models/bot/offensive_message.py +++ b/pydis_site/apps/api/models/bot/offensive_message.py @@ -9,7 +9,7 @@ from pydis_site.apps.api.models.mixins import ModelReprMixin def future_date_validator(date: datetime.date) -> None: """Raise ValidationError if the date isn't a future date.""" - if date < datetime.datetime.now(datetime.timezone.utc): + if date < datetime.datetime.now(datetime.UTC): raise ValidationError("Date must be a future date") diff --git a/pydis_site/apps/api/tests/test_deleted_messages.py b/pydis_site/apps/api/tests/test_deleted_messages.py index 62d17e58..d5501202 100644 --- a/pydis_site/apps/api/tests/test_deleted_messages.py +++ b/pydis_site/apps/api/tests/test_deleted_messages.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from django.urls import reverse @@ -17,7 +17,7 @@ class DeletedMessagesWithoutActorTests(AuthenticatedAPITestCase): cls.data = { 'actor': None, - 'creation': datetime.now(tz=timezone.utc).isoformat(), + 'creation': datetime.now(tz=UTC).isoformat(), 'deletedmessage_set': [ { 'author': cls.author.id, @@ -57,7 +57,7 @@ class DeletedMessagesWithActorTests(AuthenticatedAPITestCase): cls.data = { 'actor': cls.actor.id, - 'creation': datetime.now(tz=timezone.utc).isoformat(), + 'creation': datetime.now(tz=UTC).isoformat(), 'deletedmessage_set': [ { 'author': cls.author.id, @@ -89,7 +89,7 @@ class DeletedMessagesLogURLTests(AuthenticatedAPITestCase): cls.deletion_context = MessageDeletionContext.objects.create( actor=cls.actor, - creation=datetime.now(tz=timezone.utc), + creation=datetime.now(tz=UTC), ) def test_valid_log_url(self): diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py index 34fae875..d36111c9 100644 --- a/pydis_site/apps/api/tests/test_github_utils.py +++ b/pydis_site/apps/api/tests/test_github_utils.py @@ -39,7 +39,7 @@ class GeneralUtilityTests(unittest.TestCase): delta = datetime.timedelta(minutes=10) self.assertAlmostEqual(decoded["exp"] - decoded["iat"], delta.total_seconds()) - then = datetime.datetime.now(tz=datetime.timezone.utc) + delta + then = datetime.datetime.now(tz=datetime.UTC) + delta self.assertLess(decoded["exp"], then.timestamp()) @@ -51,7 +51,7 @@ class CheckRunTests(unittest.TestCase): "head_sha": "sha", "status": "completed", "conclusion": "success", - "created_at": datetime.datetime.now(tz=datetime.timezone.utc).strftime(settings.GITHUB_TIMESTAMP_FORMAT), + "created_at": datetime.datetime.now(tz=datetime.UTC).strftime(settings.GITHUB_TIMESTAMP_FORMAT), "artifacts_url": "url", } @@ -75,7 +75,7 @@ class CheckRunTests(unittest.TestCase): # Set the creation time to well before the MAX_RUN_TIME # to guarantee the right conclusion kwargs["created_at"] = ( - datetime.datetime.now(tz=datetime.timezone.utc) + datetime.datetime.now(tz=datetime.UTC) - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10) ).strftime(settings.GITHUB_TIMESTAMP_FORMAT) @@ -180,7 +180,7 @@ class ArtifactFetcherTests(unittest.TestCase): head_sha="action_sha", created_at=( datetime.datetime - .now(tz=datetime.timezone.utc) + .now(tz=datetime.UTC) .strftime(settings.GITHUB_TIMESTAMP_FORMAT) ), status="completed", diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index 71611ee9..b9b33ff3 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -1,5 +1,5 @@ import datetime -from datetime import datetime as dt, timedelta, timezone +from datetime import UTC, datetime as dt, timedelta from unittest.mock import patch from urllib.parse import quote @@ -56,8 +56,8 @@ class InfractionTests(AuthenticatedAPITestCase): type='ban', reason='He terk my jerb!', hidden=True, - inserted_at=dt(2020, 10, 10, 0, 0, 0, tzinfo=timezone.utc), - expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc), + inserted_at=dt(2020, 10, 10, 0, 0, 0, tzinfo=UTC), + expires_at=dt(5018, 11, 20, 15, 52, tzinfo=UTC), active=True, ) cls.ban_inactive = Infraction.objects.create( @@ -66,7 +66,7 @@ class InfractionTests(AuthenticatedAPITestCase): type='ban', reason='James is an ass, and we won\'t be working with him again.', active=False, - inserted_at=dt(2020, 10, 10, 0, 1, 0, tzinfo=timezone.utc), + inserted_at=dt(2020, 10, 10, 0, 1, 0, tzinfo=UTC), ) cls.timeout_permanent = Infraction.objects.create( user_id=cls.user.id, @@ -74,7 +74,7 @@ class InfractionTests(AuthenticatedAPITestCase): 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), + inserted_at=dt(2020, 10, 10, 0, 2, 0, tzinfo=UTC), expires_at=None, ) cls.superstar_expires_soon = Infraction.objects.create( @@ -83,8 +83,8 @@ class InfractionTests(AuthenticatedAPITestCase): type='superstar', reason='This one doesn\'t matter anymore.', active=True, - inserted_at=dt(2020, 10, 10, 0, 3, 0, tzinfo=timezone.utc), - expires_at=dt.now(timezone.utc) + datetime.timedelta(hours=5), + inserted_at=dt(2020, 10, 10, 0, 3, 0, tzinfo=UTC), + expires_at=dt.now(UTC) + datetime.timedelta(hours=5), ) cls.voiceban_expires_later = Infraction.objects.create( user_id=cls.user.id, @@ -92,8 +92,8 @@ class InfractionTests(AuthenticatedAPITestCase): type='voice_ban', reason='Jet engine mic', active=True, - inserted_at=dt(2020, 10, 10, 0, 4, 0, tzinfo=timezone.utc), - expires_at=dt.now(timezone.utc) + datetime.timedelta(days=5), + inserted_at=dt(2020, 10, 10, 0, 4, 0, tzinfo=UTC), + expires_at=dt.now(UTC) + datetime.timedelta(days=5), ) def test_list_all(self): @@ -152,7 +152,7 @@ class InfractionTests(AuthenticatedAPITestCase): def test_filter_after(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5) + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=5) response = self.client.get(url, {'type': 'superstar', 'expires_after': target_time.isoformat()}) self.assertEqual(response.status_code, 200) @@ -161,7 +161,7 @@ class InfractionTests(AuthenticatedAPITestCase): def test_filter_before(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5) + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=5) response = self.client.get(url, {'type': 'superstar', 'expires_before': target_time.isoformat()}) self.assertEqual(response.status_code, 200) @@ -185,8 +185,8 @@ class InfractionTests(AuthenticatedAPITestCase): def test_after_before_before(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=4) - target_time_late = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=6) + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=4) + target_time_late = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=6) response = self.client.get( url, {'expires_before': target_time_late.isoformat(), @@ -199,8 +199,8 @@ class InfractionTests(AuthenticatedAPITestCase): def test_after_after_before_invalid(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5) - target_time_late = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=9) + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=5) + target_time_late = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=9) response = self.client.get( url, {'expires_before': target_time.isoformat(), @@ -214,7 +214,7 @@ class InfractionTests(AuthenticatedAPITestCase): def test_permanent_after_invalid(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5) + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=5) response = self.client.get( url, {'permanent': 'true', 'expires_after': target_time.isoformat()}, @@ -226,7 +226,7 @@ class InfractionTests(AuthenticatedAPITestCase): def test_permanent_before_invalid(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5) + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=5) response = self.client.get( url, {'permanent': 'true', 'expires_before': target_time.isoformat()}, @@ -238,7 +238,7 @@ class InfractionTests(AuthenticatedAPITestCase): def test_nonpermanent_before(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=6) + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=6) response = self.client.get( url, {'permanent': 'false', 'expires_before': target_time.isoformat()}, @@ -370,7 +370,7 @@ class CreationTests(AuthenticatedAPITestCase): infraction = Infraction.objects.get(id=response.json()['id']) self.assertAlmostEqual( infraction.inserted_at, - dt.now(timezone.utc), + dt.now(UTC), delta=timedelta(seconds=2) ) self.assertEqual(infraction.expires_at.isoformat(), data['expires_at']) @@ -814,7 +814,7 @@ class SerializerTests(AuthenticatedAPITestCase): actor_id=self.user.id, type=_type, reason='A reason.', - expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc), + expires_at=dt(5018, 11, 20, 15, 52, tzinfo=UTC), active=active ) diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index 1cca133d..456ac408 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -1,4 +1,4 @@ -from datetime import datetime as dt, timezone +from datetime import UTC, datetime as dt from django.core.exceptions import ValidationError from django.test import SimpleTestCase, TestCase @@ -41,7 +41,7 @@ class NitroMessageLengthTest(TestCase): self.context = MessageDeletionContext.objects.create( id=50, actor=self.user, - creation=dt.now(timezone.utc) + creation=dt.now(UTC) ) def test_create(self): @@ -99,7 +99,7 @@ class StringDunderMethodTests(SimpleTestCase): name='shawn', discriminator=555, ), - creation=dt.now(timezone.utc) + creation=dt.now(UTC) ), embeds=[] ), @@ -118,7 +118,7 @@ class StringDunderMethodTests(SimpleTestCase): OffensiveMessage( id=602951077675139072, channel_id=291284109232308226, - delete_date=dt(3000, 1, 1, tzinfo=timezone.utc) + delete_date=dt(3000, 1, 1, tzinfo=UTC) ), OffTopicChannelName(name='bob-the-builders-playground'), Role( @@ -132,7 +132,7 @@ class StringDunderMethodTests(SimpleTestCase): name='shawn', discriminator=555, ), - creation=dt.now(tz=timezone.utc) + creation=dt.now(tz=UTC) ), User( id=5, @@ -151,7 +151,7 @@ class StringDunderMethodTests(SimpleTestCase): hidden=True, type='kick', reason='He terk my jerb!', - expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) + expires_at=dt(5018, 11, 20, 15, 52, tzinfo=UTC) ), Reminder( author=User( @@ -165,7 +165,7 @@ class StringDunderMethodTests(SimpleTestCase): '267624335836053506/291284109232308226/463087129459949587' ), content="oh no", - expiration=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) + expiration=dt(5018, 11, 20, 15, 52, tzinfo=UTC) ), NominationEntry( nomination_id=self.nomination.id, diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py index ee6b1fbd..7fe2f0a8 100644 --- a/pydis_site/apps/api/tests/test_nominations.py +++ b/pydis_site/apps/api/tests/test_nominations.py @@ -1,4 +1,4 @@ -from datetime import datetime as dt, timedelta, timezone +from datetime import UTC, datetime as dt, timedelta from django.urls import reverse @@ -38,7 +38,7 @@ class CreationTests(AuthenticatedAPITestCase): ) self.assertAlmostEqual( nomination.inserted_at, - dt.now(timezone.utc), + dt.now(UTC), delta=timedelta(seconds=2) ) self.assertEqual(nomination.user.id, data['user']) @@ -319,7 +319,7 @@ class NominationTests(AuthenticatedAPITestCase): self.assertAlmostEqual( nomination.ended_at, - dt.now(timezone.utc), + dt.now(UTC), delta=timedelta(seconds=2) ) self.assertFalse(nomination.active) diff --git a/pydis_site/apps/api/tests/test_offensive_message.py b/pydis_site/apps/api/tests/test_offensive_message.py index 53f9cb48..f45b5a66 100644 --- a/pydis_site/apps/api/tests/test_offensive_message.py +++ b/pydis_site/apps/api/tests/test_offensive_message.py @@ -16,7 +16,7 @@ class CreationTests(AuthenticatedAPITestCase): 'delete_date': delete_at.isoformat()[:-1] } - aware_delete_at = delete_at.replace(tzinfo=datetime.timezone.utc) + aware_delete_at = delete_at.replace(tzinfo=datetime.UTC) response = self.client.post(url, data=data) self.assertEqual(response.status_code, 201) @@ -73,7 +73,7 @@ class ListTests(AuthenticatedAPITestCase): @classmethod def setUpTestData(cls): delete_at = datetime.datetime.now() + datetime.timedelta(days=1) # noqa: DTZ005 - aware_delete_at = delete_at.replace(tzinfo=datetime.timezone.utc) + aware_delete_at = delete_at.replace(tzinfo=datetime.UTC) cls.messages = [ { @@ -111,7 +111,7 @@ class ListTests(AuthenticatedAPITestCase): class DeletionTests(AuthenticatedAPITestCase): @classmethod def setUpTestData(cls): - delete_at = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + delete_at = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1) cls.valid_offensive_message = OffensiveMessage.objects.create( id=602951077675139072, @@ -135,7 +135,7 @@ class DeletionTests(AuthenticatedAPITestCase): class NotAllowedMethodsTests(AuthenticatedAPITestCase): @classmethod def setUpTestData(cls): - delete_at = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + delete_at = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1) cls.valid_offensive_message = OffensiveMessage.objects.create( id=602951077675139072, diff --git a/pydis_site/apps/api/tests/test_reminders.py b/pydis_site/apps/api/tests/test_reminders.py index 9bb5fe4d..98e93bb7 100644 --- a/pydis_site/apps/api/tests/test_reminders.py +++ b/pydis_site/apps/api/tests/test_reminders.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from django.forms.models import model_to_dict from django.urls import reverse @@ -59,7 +59,7 @@ class ReminderCreationTests(AuthenticatedAPITestCase): data = { 'author': self.author.id, 'content': 'Remember to...wait what was it again?', - 'expiration': datetime.now(tz=timezone.utc).isoformat(), + 'expiration': datetime.now(tz=UTC).isoformat(), 'jump_url': "https://www.google.com", 'channel_id': 123, 'mentions': [8888, 9999], @@ -91,7 +91,7 @@ class ReminderDeletionTests(AuthenticatedAPITestCase): cls.reminder = Reminder.objects.create( author=cls.author, content="Don't forget to set yourself a reminder", - expiration=datetime.now(timezone.utc), + expiration=datetime.now(UTC), jump_url="https://www.decliningmentalfaculties.com", channel_id=123 ) @@ -122,7 +122,7 @@ class ReminderListTests(AuthenticatedAPITestCase): cls.reminder_one = Reminder.objects.create( author=cls.author, content="We should take Bikini Bottom, and push it somewhere else!", - expiration=datetime.now(timezone.utc), + expiration=datetime.now(UTC), jump_url="https://www.icantseemyforehead.com", channel_id=123 ) @@ -130,7 +130,7 @@ class ReminderListTests(AuthenticatedAPITestCase): cls.reminder_two = Reminder.objects.create( author=cls.author, content="Gahhh-I love being purple!", - expiration=datetime.now(timezone.utc), + expiration=datetime.now(UTC), jump_url="https://www.goofygoobersicecreampartyboat.com", channel_id=123, active=False @@ -176,7 +176,7 @@ class ReminderRetrieveTests(AuthenticatedAPITestCase): cls.reminder = Reminder.objects.create( author=cls.author, content="Reminder content", - expiration=datetime.now(timezone.utc), + expiration=datetime.now(UTC), jump_url="http://example.com/", channel_id=123 ) @@ -204,7 +204,7 @@ class ReminderUpdateTests(AuthenticatedAPITestCase): cls.reminder = Reminder.objects.create( author=cls.author, content="Squash those do-gooders", - expiration=datetime.now(timezone.utc), + expiration=datetime.now(UTC), jump_url="https://www.decliningmentalfaculties.com", channel_id=123 ) diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py index a7ec6e38..abff8f55 100644 --- a/pydis_site/apps/api/tests/test_validators.py +++ b/pydis_site/apps/api/tests/test_validators.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from django.core.exceptions import ValidationError from django.test import TestCase @@ -23,8 +23,8 @@ class BotSettingValidatorTests(TestCase): class OffensiveMessageValidatorsTests(TestCase): def test_accepts_future_date(self): - future_date_validator(datetime(3000, 1, 1, tzinfo=timezone.utc)) + future_date_validator(datetime(3000, 1, 1, tzinfo=UTC)) def test_rejects_non_future_date(self): with self.assertRaises(ValidationError): - future_date_validator(datetime(1000, 1, 1, tzinfo=timezone.utc)) + future_date_validator(datetime(1000, 1, 1, tzinfo=UTC)) diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index ec8b83a1..26cae3ad 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -190,7 +190,7 @@ class InfractionViewSet( except ValueError: raise ValidationError({'expires_after': ['failed to convert to datetime']}) additional_filters['expires_at__gte'] = expires_after_parsed.replace( - tzinfo=datetime.timezone.utc + tzinfo=datetime.UTC ) filter_expires_before = self.request.query_params.get('expires_before') @@ -200,7 +200,7 @@ class InfractionViewSet( except ValueError: raise ValidationError({'expires_before': ['failed to convert to datetime']}) additional_filters['expires_at__lte'] = expires_before_parsed.replace( - tzinfo=datetime.timezone.utc + tzinfo=datetime.UTC ) if 'expires_at__lte' in additional_filters and 'expires_at__gte' in additional_filters: diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py index 462818b5..7f7736f9 100644 --- a/pydis_site/apps/content/tests/test_utils.py +++ b/pydis_site/apps/content/tests/test_utils.py @@ -17,7 +17,7 @@ from pydis_site.apps.content.tests.helpers import ( BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA ) -_time = datetime.datetime(2022, 10, 10, 10, 10, 10, tzinfo=datetime.timezone.utc) +_time = datetime.datetime(2022, 10, 10, 10, 10, 10, tzinfo=datetime.UTC) _time_str = _time.strftime(settings.GITHUB_TIMESTAMP_FORMAT) TEST_COMMIT_KWARGS = { "sha": "123", diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index 347640dd..56f6283d 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -132,7 +132,7 @@ def set_tag_commit(tag: Tag) -> None: tag.last_commit = Commit( sha="68da80efc00d9932a209d5cccd8d344cec0f09ea", message="Initial Commit\n\nTHIS IS FAKE DEMO DATA", - date=datetime.datetime(2018, 2, 3, 12, 20, 26, tzinfo=datetime.timezone.utc), + date=datetime.datetime(2018, 2, 3, 12, 20, 26, tzinfo=datetime.UTC), authors=json.dumps([{"name": "Joseph", "email": "joseph@josephbanks.me"}]), ) return @@ -154,7 +154,7 @@ def set_tag_commit(tag: Tag) -> None: date = ( datetime.datetime .strptime(committer["date"], settings.GITHUB_TIMESTAMP_FORMAT) - .replace(tzinfo=datetime.timezone.utc) + .replace(tzinfo=datetime.UTC) ) if author["email"] == committer["email"]: -- cgit v1.2.3 From 548a81e0e3060483b693079f994fcb70e01084e7 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Tue, 16 May 2023 23:04:04 +0530 Subject: condesed the 2 examples in 1 and added additional comments for reference. --- .../guides/python-guides/subclassing_bot.md | 70 +++++++++++----------- 1 file changed, 35 insertions(+), 35 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 91df2199..2562d606 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -4,55 +4,55 @@ description: "Subclassing the discord.py Bot class to add more functionality and --- ## Basic Subclassing -First, a [basic article](https://www.codesdope.com/course/python-subclass-of-a-class/) on subclassing will provide some fundamental knowledge, which is highly suggested before moving on to this topic, as subclassing [`Bot`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot) can ultimately be a complicated task. +First, a [basic article](https://www.codesdope.com/course/python-subclass-of-a-class/) on subclassing will provide some fundamental knowledge, which is highly suggested before moving on to this topic, as subclassing [`Bot`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot) can ultimately be a complicated task. ## The benefits of subclassing bot Subclassing Bot can be very beneficial as it provides you with more control and customisability of how your bot functions, also allowing you to add extra features, such as custom bot attributes or methods. For example, the default [Context](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) can be overriden to add more functionality. -There are two ways to subclass `commands.Bot`, as shown below: +You can subclass `commands.Bot` as shown below: ```python class CustomBot(commands.Bot): - def __init__(self): - super().__init__( - command_prefix= # Your prefix here as a string - intents= # Your intents here - # Other kwargs can be put here - ) - # custom bot attributes can be set here, for example: + def __init__(self, *args, **kwargs) -> None: + # Forward all arguments, and keyword-only arguments to commands.Bot + super().__init__(*args, **kwargs) + + # Custom bot attributes can be set here. self.launch_time = datetime.datetime.utcnow() self.example_integer = 5 - - async def start(self, *args, **kwargs): - # here you are overriding the default start method. You can do some code here for example establish a database connection - # as shown in this example below - self.db = await aiosqlite.connect('database file name.db') + # Here you are overriding the default start method and write your own code. + async def start(self, *args, **kwargs) -> None: + """Establish a database connection.""" + self.db = await aiosqlite.connect('sqlite.db') await super().start(*args, **kwargs) + # Example of a custom bot method + def status(self) -> str: + """Get bot launch time in UTC and status.""" + return f"Bot started at {self.launch_time}, running." -bot = CustomBot() -token = YOUR_TOKEN_HERE -bot.run(token) -``` -Or -```python -class CustomBot(commands.Bot): - def __init__(self, *args, **kwargs): # the key-word arguments are not specified here, unlike the example above +# All arguments as passed to commands.Bot can be passed here. +bot = CustomBot( + command_prefix="!", # Prefix can be set to any string. + # Discord intents, refer to https://discordpy.readthedocs.io/en/stable/intents.html + intents=discord.Intents.default() +) - super().__init__(*args, **kwargs) - # custom bot attributes can be set here, for example: - self.example_string = 'This is an example!' - # You can add a custom bot method, anyhting can be done in this function. This is an example: - def hello(self): - return 'Hello World' +# Example bot command +@bot.command() +async def ping(ctx): + """ + Creates a command with the name `ping`. -# Here you set the *args and **kwargs -bot = CustomBot(command_prefix="!", intents=discord.Intents.default()) + When invoked, sends `pong`. + """ + await ctx.send("pong") -@bot.command() -async def example(ctx): - print(bot.hello()) - # In this case, this will print Hello World! + +# Having the token as an environment variable is recommended. +# Refer to https://www.pythondiscord.com/pages/guides/python-guides/keeping-tokens-safe/ +token = YOUR_TOKEN_HERE +bot.run(token) ``` -With either of the above examples, you are not required to change any of the existing or future code, it is identical to code done without subclassing bot. +With the above example, you are not required to change any of the existing or future code, it is identical to code done without subclassing bot. -- cgit v1.2.3 From e3576f83993109b49711a2c7c705b2a833107832 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 17 May 2023 00:47:24 +0530 Subject: use launch_time attribute in the example method and command --- .../guides/python-guides/subclassing_bot.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 2562d606..47186e3d 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -1,13 +1,13 @@ --- title: Subclassing Bot -description: "Subclassing the discord.py Bot class to add more functionality and customisability." +description: "Subclassing the discord.py Bot class to add more functionality and customizability." --- ## Basic Subclassing First, a [basic article](https://www.codesdope.com/course/python-subclass-of-a-class/) on subclassing will provide some fundamental knowledge, which is highly suggested before moving on to this topic, as subclassing [`Bot`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot) can ultimately be a complicated task. -## The benefits of subclassing bot -Subclassing Bot can be very beneficial as it provides you with more control and customisability of how your bot functions, also allowing you to add extra features, such as custom bot attributes or methods. For example, the default [Context](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) can be overriden to add more functionality. +## The Benefits of Subclassing Bot +Subclassing `Bot` can be very beneficial as it provides you with more control and customizability of how your bot functions, also allowing you to add extra features, such as custom bot attributes or methods. For example, the default [Context](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) can be overridden to add more functionality. You can subclass `commands.Bot` as shown below: ```python @@ -27,9 +27,9 @@ class CustomBot(commands.Bot): await super().start(*args, **kwargs) # Example of a custom bot method - def status(self) -> str: - """Get bot launch time in UTC and status.""" - return f"Bot started at {self.launch_time}, running." + def get_launch_time_str(self) -> datetime.datetime: + """Get bot launch datetime without milliseconds in UTC and status.""" + return f"Bot started at: {self.launch_time.strftime('%F %T')} UTC." # All arguments as passed to commands.Bot can be passed here. bot = CustomBot( @@ -41,13 +41,13 @@ bot = CustomBot( # Example bot command @bot.command() -async def ping(ctx): +async def start_time(ctx): """ - Creates a command with the name `ping`. + Creates a command with the name `start_time`. - When invoked, sends `pong`. + When invoked, sends the output of the custom method `get_launch_time_str`. """ - await ctx.send("pong") + await ctx.send(bot.get_launch_time_str()) # Having the token as an environment variable is recommended. -- cgit v1.2.3 From 9b0e2711f15ed2e1aa5c080a716b0e9f1691dbd6 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 17 May 2023 00:58:11 +0530 Subject: correct return type annotation for get_launch_time_str function --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 47186e3d..1be6e485 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -27,7 +27,7 @@ class CustomBot(commands.Bot): await super().start(*args, **kwargs) # Example of a custom bot method - def get_launch_time_str(self) -> datetime.datetime: + def get_launch_time_str(self) -> str: """Get bot launch datetime without milliseconds in UTC and status.""" return f"Bot started at: {self.launch_time.strftime('%F %T')} UTC." @@ -56,3 +56,4 @@ token = YOUR_TOKEN_HERE bot.run(token) ``` With the above example, you are not required to change any of the existing or future code, it is identical to code done without subclassing bot. +ty \ No newline at end of file -- cgit v1.2.3 From 16e23cd5f817908a5f2bdba3c2dea76c012172de Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 17 May 2023 01:00:43 +0530 Subject: highlight 'Bot' in the description with backticks. --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 1be6e485..da71fdba 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -1,6 +1,6 @@ --- title: Subclassing Bot -description: "Subclassing the discord.py Bot class to add more functionality and customizability." +description: "Subclassing the discord.py `Bot` class to add more functionality and customizability." --- ## Basic Subclassing -- cgit v1.2.3 From 01c5c6614ca65216db13e9c420b139bda2c29757 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 17 May 2023 01:02:14 +0530 Subject: remove typo --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index da71fdba..f2b70e33 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -55,5 +55,4 @@ async def start_time(ctx): token = YOUR_TOKEN_HERE bot.run(token) ``` -With the above example, you are not required to change any of the existing or future code, it is identical to code done without subclassing bot. -ty \ No newline at end of file +With the above example, you are not required to change any of the existing or future code, it is identical to code done without subclassing bot. \ No newline at end of file -- cgit v1.2.3 From c62158ed52e3e593624ec2f944653277b758d70a Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 17 May 2023 12:06:12 +0530 Subject: give a simple introduction to cogs and example links --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index f2b70e33..cbb2ad83 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -55,4 +55,4 @@ async def start_time(ctx): token = YOUR_TOKEN_HERE bot.run(token) ``` -With the above example, you are not required to change any of the existing or future code, it is identical to code done without subclassing bot. \ No newline at end of file +The next step would be to look into discord.py cogs as they help in organizing of collection of commands into various files and folders. Refer to https://discordpy.readthedocs.io/en/stable/ext/commands/cogs.html for more on cogs. Here is an example of cogs https://gist.github.com/EvieePy/d78c061a4798ae81be9825468fe146be (*Credit to EvieePy*). \ No newline at end of file -- cgit v1.2.3 From b4f764fdb285be44b017f7cc6c5816455bbe0b53 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 17 May 2023 12:16:18 +0530 Subject: satisfy linter --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index cbb2ad83..7a5a3529 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -55,4 +55,4 @@ async def start_time(ctx): token = YOUR_TOKEN_HERE bot.run(token) ``` -The next step would be to look into discord.py cogs as they help in organizing of collection of commands into various files and folders. Refer to https://discordpy.readthedocs.io/en/stable/ext/commands/cogs.html for more on cogs. Here is an example of cogs https://gist.github.com/EvieePy/d78c061a4798ae81be9825468fe146be (*Credit to EvieePy*). \ No newline at end of file +The next step would be to look into discord.py cogs as they help in organizing of collection of commands into various files and folders. Refer to https://discordpy.readthedocs.io/en/stable/ext/commands/cogs.html for more on cogs. Here is an example of cogs https://gist.github.com/EvieePy/d78c061a4798ae81be9825468fe146be (*Credit to EvieePy*). -- cgit v1.2.3 From b33330fb11b8ac9c63e29ea56ed76aeabfe17d24 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 17 May 2023 17:45:35 +0530 Subject: remove redundant info --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 7a5a3529..1cdf9c14 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -55,4 +55,4 @@ async def start_time(ctx): token = YOUR_TOKEN_HERE bot.run(token) ``` -The next step would be to look into discord.py cogs as they help in organizing of collection of commands into various files and folders. Refer to https://discordpy.readthedocs.io/en/stable/ext/commands/cogs.html for more on cogs. Here is an example of cogs https://gist.github.com/EvieePy/d78c061a4798ae81be9825468fe146be (*Credit to EvieePy*). +The next step would be to look into discord.py cogs as they help in organizing collections of commands into various files and folders. Refer to [the official docs](https://discordpy.readthedocs.io/en/stable/ext/commands/cogs.html) for more on them. -- cgit v1.2.3 From 5a2c346cb07eb746e3d3532cf38dadcfa983e13e Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 17 May 2023 20:16:11 +0530 Subject: add reference link for overriding context and fix link for subclassing bot --- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 1cdf9c14..6a79190f 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -7,7 +7,7 @@ description: "Subclassing the discord.py `Bot` class to add more functionality a First, a [basic article](https://www.codesdope.com/course/python-subclass-of-a-class/) on subclassing will provide some fundamental knowledge, which is highly suggested before moving on to this topic, as subclassing [`Bot`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot) can ultimately be a complicated task. ## The Benefits of Subclassing Bot -Subclassing `Bot` can be very beneficial as it provides you with more control and customizability of how your bot functions, also allowing you to add extra features, such as custom bot attributes or methods. For example, the default [Context](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) can be overridden to add more functionality. +Subclassing `Bot` can be very beneficial as it provides you with more control and customizability of how your bot functions, also allowing you to add extra features, such as custom bot attributes or methods. For example, the default [Context](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) can be [overridden](../discordpy-subclassing-context.md) to add more functionality. You can subclass `commands.Bot` as shown below: ```python -- cgit v1.2.3 From cc54cb8890813f4dd781f510d1b44d155d0e8b0d Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 17 May 2023 20:16:18 +0530 Subject: add reference link for overriding context and fix link for subclassing bot --- .../resources/guides/python-guides/discordpy-subclassing-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') 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 index b77cb0f9..7f43c50f 100644 --- 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 @@ -3,7 +3,7 @@ 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 +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 -- cgit v1.2.3 From a1da70faf493d66c714903328bf79986e7b1dd89 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 17 May 2023 20:36:48 +0530 Subject: remove .md for hyperlinks --- .../resources/guides/python-guides/discordpy-subclassing-context.md | 2 +- .../apps/content/resources/guides/python-guides/subclassing_bot.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') 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 index 7f43c50f..5e5f05c1 100644 --- 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 @@ -3,7 +3,7 @@ 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 +Start by reading the guide on [subclassing the `Bot` class](../subclassing_bot). A subclass of Bot has to be used to inject your custom context subclass into discord.py. ## Overview diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md index 6a79190f..8982a4f6 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -7,7 +7,7 @@ description: "Subclassing the discord.py `Bot` class to add more functionality a First, a [basic article](https://www.codesdope.com/course/python-subclass-of-a-class/) on subclassing will provide some fundamental knowledge, which is highly suggested before moving on to this topic, as subclassing [`Bot`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot) can ultimately be a complicated task. ## The Benefits of Subclassing Bot -Subclassing `Bot` can be very beneficial as it provides you with more control and customizability of how your bot functions, also allowing you to add extra features, such as custom bot attributes or methods. For example, the default [Context](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) can be [overridden](../discordpy-subclassing-context.md) to add more functionality. +Subclassing `Bot` can be very beneficial as it provides you with more control and customizability of how your bot functions, also allowing you to add extra features, such as custom bot attributes or methods. For example, the default [Context](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) can be [overridden](../discordpy-subclassing-context) to add more functionality. You can subclass `commands.Bot` as shown below: ```python -- cgit v1.2.3 From 6fcf0c5caa341f23c73dfe4934dcc93971c389c3 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Tue, 30 May 2023 12:11:36 +0100 Subject: Remove mentions of there being multiple snekbox containers (#988) With https://github.com/python-discord/bot/pull/2618 there will only be 1 snekbox container, that runs the latest verison of snekbox. Supporting multiple versions of snekbox will be covered by https://github.com/python-discord/snekbox/issues/158 where a single instance of snekbox will nativly support multiple Python verisons. --- .../pydis-guides/contributing/bot-extended-configuration-options.md | 3 +-- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md index b031a668..a35d79a9 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md @@ -115,14 +115,13 @@ The advantage of this method is that you can run the bot's code in your preferre * Append the following line to your `.env` file: `API_KEYS_SITE_API=badbot13m0n8f570f942013fc818f234916ca531`. * In your `.env.server` file, set `urls_site_api="http://localhost:8000/api"`. If you wish to keep using `http://web:8000/api`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. -* To work with snekbox, set `urls_snekbox_eval_api="http://localhost:8060/eval"` and `urls_snekbox_311_eval_api="http://localhost:8065/eval"` +* To work with snekbox, set `urls_snekbox_eval_api="http://localhost:8060/eval"`. You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: * `docker compose up web` to start the site container. This is required. * `docker compose up snekbox` to start the snekbox container. You only need this if you're planning on working on the snekbox cog. -* `docker compose up snekbox-311` to start the snekbox 3.11 container. You only need this if you're planning on working on the snekbox cog. * `docker compose up redis` to start the Redis container. You only need this if you're not using fakeredis. For more info refer to [Working with Redis](#optional-working-with-redis). You can start several services together: `docker compose up web snekbox redis`. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 6639d92d..b5e11a97 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -134,7 +134,6 @@ Some other useful docker commands are as follows: 1. `docker compose pull` this pulls updates for all non-bot services, such as psotgres, redis and our [site](../site) project! 1. `docker compose build` this rebuilds the bot's docker image, this is only needed if you need to make changes to the bot's dependencies, or the Dockerfile itself. -1. `docker compose --profile 3.10 up` this starts a 3.10 snekbox container, in addition to the default 3.11 container! Your bot is now running, all inside Docker. -- cgit v1.2.3 From d836a23df4684d6381f3a408b1349808d0504f16 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 21 Jun 2023 13:14:39 +0200 Subject: Update code for new linter rules Ignore RUF012, which suggests to add typing.ClassVar to "mutable" class variables (in our case, it complained about the ordering attribute of the `Meta` class within models) - annotating this would be a bit noisy. --- pydis_site/apps/api/viewsets/bot/user.py | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py index 88fa3415..77378336 100644 --- a/pydis_site/apps/api/viewsets/bot/user.py +++ b/pydis_site/apps/api/viewsets/bot/user.py @@ -285,7 +285,7 @@ class UserViewSet(ModelViewSet): return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=True) - def metricity_data(self, request: Request, pk: str = None) -> Response: + def metricity_data(self, request: Request, pk: str | None = None) -> Response: """Request handler for metricity_data endpoint.""" user = self.get_object() @@ -308,7 +308,7 @@ class UserViewSet(ModelViewSet): status=status.HTTP_404_NOT_FOUND) @action(detail=True) - def metricity_review_data(self, request: Request, pk: str = None) -> Response: + def metricity_review_data(self, request: Request, pk: str | None = None) -> Response: """Request handler for metricity_review_data endpoint.""" user = self.get_object() diff --git a/pyproject.toml b/pyproject.toml index 56d1f9bb..2f830df0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ ignore = [ "E731", "DJ001", "DJ008", "RET504", - "RUF005", + "RUF005", "RUF012", "S311", "SIM102", "SIM108", ] -- cgit v1.2.3 From 932f5aab053f634877b77b97224b42ee893456e6 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Thu, 22 Jun 2023 15:21:40 +0530 Subject: Fix broken links in the contributing guide. (#1002) --- pydis_site/apps/content/resources/guides/pydis-guides/contributing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md index 07e9a7bd..b36c0afd 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing.md @@ -82,7 +82,7 @@ Our projects on Python Discord are open source and [available on GitHub](https:/ # How do I start contributing? Unsure of what contributing to open source projects involves? Have questions about how to use GitHub? Just need to know about our contribution etiquette? Completing these steps will have you ready to make your first contribution no matter your starting point. -Feel free to skip any steps you're already familiar with, but please make sure not to miss the [Contributing Guidelines](#5-read-our-contributing-guidelines). +Feel free to skip any steps you're already familiar with, but please make sure not to miss the [Contributing Guidelines](#3-read-our-contributing-guidelines). If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides. @@ -129,7 +129,7 @@ Don't move forward until your issue is approved by a Core Developer. Issues are ### 5. Make changes Now it is time to make the changes to fulfill your approved issue. You should create a new Git branch for your feature; that way you can keep your main branch up to date with ours and even work on multiple features at once in separate branches. -This is a good time to review [how to write good commit messages](./contributing-guidelines/commit-messages) if you haven't already. +This is a good time to review [how to write good commit messages](./commit-messages) if you haven't already. ### 6. Open a pull request After your issue has been approved and you've written your code and tested it, it's time to open a pull request. Pull requests are a feature in GitHub; you can think of them as asking the project maintainers to accept your changes. This gives other contributors a chance to review your code and make any needed changes before it's merged into the main branch of the project. -- cgit v1.2.3 From 5031f238fbbb96aabe9e03bf6741a1b4f0aeadca Mon Sep 17 00:00:00 2001 From: Daniel Gu Date: Sun, 25 Jun 2023 10:46:28 +0800 Subject: Remove obsolete question from FAQ --- pydis_site/apps/content/resources/frequently-asked-questions.md | 6 ------ 1 file changed, 6 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/frequently-asked-questions.md b/pydis_site/apps/content/resources/frequently-asked-questions.md index 1c9c3f6d..bef1f1ea 100644 --- a/pydis_site/apps/content/resources/frequently-asked-questions.md +++ b/pydis_site/apps/content/resources/frequently-asked-questions.md @@ -75,12 +75,6 @@ If you have any questions about how to contribute, drop by the `#dev-contrib` ch ## Server Specific Questions -#### **Q: Why are the help channels named after elements/food?** - -We want to keep the help channels uniquely named with it being somewhat easy to remember, so we decided on elements/food. `#help-1` doesn't work as well as `help-carbon` (`help-strawberry`). -If we had them numbered, they would quickly move out of order and possibly cause confusion for newer members. - - #### **Q: Why can't I upload a specific file type?** The only file types that we allow on this server are those that Discord supports a native preview for. -- cgit v1.2.3 From 855e4e55850be6f1bf5168783cd3e1913a616f9b Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 26 Jun 2023 11:30:24 +0200 Subject: Add new rule aliases (#1007) * Correct indent for GPT rule * Add new aliases for rule 4 and 8 See suggestion in #1001. Closes #1001. --- pydis_site/apps/api/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 32f41667..f155aa0b 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -146,7 +146,7 @@ class RulesView(APIView): ( "Use English to the best of your ability. " "Be polite if someone speaks English imperfectly.", - ["english", "language"] + ["english", "eng", "language"] ), ( "Do not provide or request help on projects that may violate terms of service, " @@ -165,15 +165,15 @@ class RulesView(APIView): ( "Do not help with ongoing exams. When helping with homework, " "help people learn how to do the assignment without doing it for them.", - ["exam", "exams", "assignment", "assignments", "homework"] + ["exam", "exams", "assignment", "assignments", "homework", "hw"] ), ( "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"] + "Do not copy and paste answers from ChatGPT or similar AI tools.", + ["gpt", "chatgpt", "gpt3", "ai"] ), ]) -- cgit v1.2.3 From c95e2a2470442b1e83b2c9c1c928afb7730df763 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 9 Jul 2023 22:32:51 +0200 Subject: Fix "PostegreSQL" typo --- .../apps/content/resources/guides/pydis-guides/contributing/site.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md index 9786698b..b6da5ba3 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md @@ -73,7 +73,7 @@ detailed information about these settings. #### Notes regarding `DATABASE_URL` - If the database is hosted locally i.e. on the same machine as the webserver, then use `localhost` for the host. Windows and macOS users may need to use the [Docker host IP](https://stackoverflow.com/questions/22944631/how-to-get-the-ip-address-of-the-docker-host-from-inside-a-docker-container) instead. -- If the database is running in Docker, use port `7777`. Otherwise, use `5432` as that is the default port used by PostegreSQL. +- If the database is running in Docker, use port `7777`. Otherwise, use `5432` as that is the default port used by PostgreSQL. - If you configured PostgreSQL in a different manner or you are not hosting it locally, then you will need to determine the correct host and port yourself. The user, password, and database name should all still be `pysite` unless you deviated from the setup instructions in the previous section. -- cgit v1.2.3 From 3c0e58bb70fbfff4c4316c3d7eaa47cd442aa0aa Mon Sep 17 00:00:00 2001 From: jchristgit Date: Tue, 11 Jul 2023 16:44:20 +0200 Subject: Drop redirect usage from URL references (#1016) Just one separate, and present beforehand, broken link remains: $ httrack -E --robots=0 --spider http://127.0.0.1:8000 -v | grep 404 20:18:51 Error: "Not Found" (404) at link 127.0.0.1:8000/events/game-jam-2020/technical-requirements/ (from 127.0.0.1:8000/events/game-jams/2020/judging/) Closes #681. --- README.md | 2 +- .../resources/guides/pydis-guides/contributing/hosts-file.md | 2 +- .../resources/guides/pydis-guides/contributing/site.md | 2 +- .../guides/pydis-guides/how-to-contribute-a-page.md | 12 ++++++------ pydis_site/apps/content/resources/server-info/roles.md | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) (limited to 'pydis_site/apps') diff --git a/README.md b/README.md index ef70697d..a37a46b7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ If you're looking to contribute or play around with the code, take a look at [th [7]: https://coveralls.io/repos/github/python-discord/site/badge.svg?branch=main [8]: https://coveralls.io/github/python-discord/site?branch=main [9]: https://pythondiscord.com -[10]: https://pythondiscord.com/pages/contributing/site/ +[10]: https://pythondiscord.com/pages/guides/pydis-guides/contributing/site/ [11]: https://github.com/python-discord/site/issues [12]: https://raw.githubusercontent.com/python-discord/branding/main/logos/badge/badge_github.svg [13]: https://discord.gg/python diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/hosts-file.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/hosts-file.md index bba5722d..2da88b61 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/hosts-file.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/hosts-file.md @@ -8,7 +8,7 @@ toc: 3 # What's a hosts file? The hosts file maps a hostname/domain to an IP address, allowing you to visit a given domain on your browser and have it resolve by your system to the given IP address, even if it's pointed back to your own system or network. -When staging a local [Site](https://pythondiscord.com/pages/contributing/site/) project, you may want to add an entries to your hosts file so you can visit the site with the domain `http://pythondiscord.local`. This is purely for convenience, and you can use `localhost` or `127.0.0.1` instead if you prefer. +When staging a local [Site](https://pythondiscord.com/pages/guides/pydis-guides/contributing/site/) project, you may want to add an entries to your hosts file so you can visit the site with the domain `http://pythondiscord.local`. This is purely for convenience, and you can use `localhost` or `127.0.0.1` instead if you prefer. # What to add You would add the following entry to your hosts file. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md index b6da5ba3..9eef8d23 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md @@ -92,7 +92,7 @@ docker-compose up The `-d` option can be appended to the command to run in detached mode. This runs the containers in the background so the current terminal session is available for use with other things. -If you get any Docker related errors, reference the [Possible Issues](https://pythondiscord.com/pages/contributing/docker/#possible-issues") section of the Docker page. +If you get any Docker related errors, reference the [Possible Issues](https://pythondiscord.com/pages/guides/pydis-guides/contributing/docker/#possible-issues") section of the Docker page. {: .notification .is-warning } ## Run on the host diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md b/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md index 0d14ef41..65a402fd 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md @@ -3,8 +3,8 @@ title: How to Contribute a Page description: Learn how to write and publish a page to this website. icon: fas fa-info relevant_links: - Contributing to Site: https://pythondiscord.com/pages/contributing/site/ - Using Git: https://pythondiscord.com/pages/contributing/working-with-git/ + Contributing to Site: https://pythondiscord.com/pages/guides/pydis-guides/contributing/site/ + Using Git: https://pythondiscord.com/pages/guides/pydis-guides/contributing/working-with-git/ toc: 4 --- @@ -14,8 +14,8 @@ If you are interested in writing or modifying pages seen here on the site, follo For further assistance and help with contributing pages, send a message to the `#dev-contrib` channel in the Discord server! ## Prerequisites -Before working on a new page, you have to [setup the site project locally](https://pythondiscord.com/pages/contributing/site/). -It is also a good idea to familiarize yourself with the [git workflow](https://pythondiscord.com/pages/contributing/working-with-git/), as it is part of the contribution workflow. +Before working on a new page, you have to [setup the site project locally](https://pythondiscord.com/pages/guides/pydis-guides/contributing/site/). +It is also a good idea to familiarize yourself with the [git workflow](https://pythondiscord.com/pages/guides/pydis-guides/contributing/working-with-git/), as it is part of the contribution workflow. Additionally, please submit your proposed page or modification to a page as an [issue in the site repository](https://github.com/python-discord/site/issues), or discuss it in the `#dev-contrib` channel in the server. As website changes require staff approval, discussing the page content beforehand helps with accelerating the contribution process, and avoids wasted work in the event the proposed page is not accepted. @@ -68,8 +68,8 @@ title: How to Contribute a Page description: Learn how to write and publish a page to this website. icon: fas fa-info relevant_links: - Contributing to Site: https://pythondiscord.com/pages/contributing/site/ - Using Git: https://pythondiscord.com/pages/contributing/working-with-git/ + Contributing to Site: https://pythondiscord.com/pages/guides/pydis-guides/contributing/site/ + Using Git: https://pythondiscord.com/pages/guides/pydis-guides/contributing/working-with-git/ --- Pages, which include guides, articles, and other static content,... diff --git a/pydis_site/apps/content/resources/server-info/roles.md b/pydis_site/apps/content/resources/server-info/roles.md index dc4240d6..4dadf6b8 100644 --- a/pydis_site/apps/content/resources/server-info/roles.md +++ b/pydis_site/apps/content/resources/server-info/roles.md @@ -33,7 +33,7 @@ It’s difficult to precisely quantify contributions, but we’ve come up with t - The member has made several significant contributions to our projects. - The member has a positive influence in our contributors subcommunity. -The role will be assigned at the discretion of the Admin Team in consultation with the Core Developers Team. Check out our [walkthrough](/pages/contributing/) to get started contributing. +The role will be assigned at the discretion of the Admin Team in consultation with the Core Developers Team. Check out our [walkthrough](/pages/guides/pydis-guides/contributing/) to get started contributing. --- -- cgit v1.2.3 From a10b199e250e5d468591a570d6ee052f2ba16a3d Mon Sep 17 00:00:00 2001 From: jchristgit Date: Sat, 15 Jul 2023 15:03:34 +0200 Subject: Fix broken link to sololearn's Python course (#1031) Fixes #1021. --- pydis_site/apps/resources/resources/sololearn.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/sololearn.yaml b/pydis_site/apps/resources/resources/sololearn.yaml index 998f5368..1c90a597 100644 --- a/pydis_site/apps/resources/resources/sololearn.yaml +++ b/pydis_site/apps/resources/resources/sololearn.yaml @@ -3,7 +3,7 @@ description: SoloLearn's Python 3 course serves as a simple and convenient intro you can pick it up and put it down between your busier aspects of life thanks to both PC and mobile apps being available to use. name: SoloLearn -title_url: https://www.sololearn.com/Course/Python/ +title_url: https://www.sololearn.com/learn/courses/python-introduction tags: topics: - general -- cgit v1.2.3 From b10f71fc43b3ff33ae84611bfa23b6830ed0d6d2 Mon Sep 17 00:00:00 2001 From: jchristgit Date: Sat, 15 Jul 2023 15:03:49 +0200 Subject: Fix broken image links for kivy (#1032) Closes #1020. --- pydis_site/apps/resources/resources/getting_started_with_kivy.yaml | 2 +- pydis_site/apps/resources/resources/kivy.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/getting_started_with_kivy.yaml b/pydis_site/apps/resources/resources/getting_started_with_kivy.yaml index 06eb2c14..0a020d6b 100644 --- a/pydis_site/apps/resources/resources/getting_started_with_kivy.yaml +++ b/pydis_site/apps/resources/resources/getting_started_with_kivy.yaml @@ -1,7 +1,7 @@ description: A big list of excellent resources for getting started making Kivy applications. name: Getting Started with Kivy title_url: https://blog.kivy.org/2019/12/getting-started-with-kivy/ -icon_image: https://raw.githubusercontent.com/kivy/kivy-website/master/logos/kivy-logo-black-256.png +icon_image: https://raw.githubusercontent.com/kivy/kivy-website/master/content/logos/kivy-logo-black-256.png tags: topics: - user interface diff --git a/pydis_site/apps/resources/resources/kivy.yaml b/pydis_site/apps/resources/resources/kivy.yaml index b1f57483..e9c14129 100644 --- a/pydis_site/apps/resources/resources/kivy.yaml +++ b/pydis_site/apps/resources/resources/kivy.yaml @@ -2,7 +2,7 @@ name: Kivy description: The Kivy project, through the Kivy framework and its sister projects, aims to provide all the tools to create desktop and mobile applications in Python. Allowing rapid development of multitouch applications with custom and exciting user interfaces. -icon_image: https://raw.githubusercontent.com/kivy/kivy-website/master/logos/kivy-logo-black-256.png +icon_image: https://raw.githubusercontent.com/kivy/kivy-website/master/content/logos/kivy-logo-black-256.png icon_size: 50 title_image: https://i.imgur.com/EVP3jZR.png title_url: https://discord.gg/djPtTRJ -- cgit v1.2.3 From 2227c8d42846d45e0529dfe9f56e24f938e60d86 Mon Sep 17 00:00:00 2001 From: jchristgit Date: Sat, 15 Jul 2023 15:05:29 +0200 Subject: Fix duplicated links in resources (#1034) * Add test for duplicate links * Remove duplicated links Closes #1022. Closes #1023. Closes #1024. --- pydis_site/apps/resources/resources/kivy.yaml | 5 +---- pydis_site/apps/resources/resources/panda3d.yaml | 5 +---- .../resources/resources/people_postgres_data.yaml | 5 +---- .../apps/resources/tests/test_resource_data.py | 25 ++++++++++++++++++++++ 4 files changed, 28 insertions(+), 12 deletions(-) create mode 100644 pydis_site/apps/resources/tests/test_resource_data.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/resources/resources/kivy.yaml b/pydis_site/apps/resources/resources/kivy.yaml index e9c14129..f49c33fb 100644 --- a/pydis_site/apps/resources/resources/kivy.yaml +++ b/pydis_site/apps/resources/resources/kivy.yaml @@ -5,11 +5,8 @@ description: The Kivy project, through the Kivy framework and its sister project icon_image: https://raw.githubusercontent.com/kivy/kivy-website/master/content/logos/kivy-logo-black-256.png icon_size: 50 title_image: https://i.imgur.com/EVP3jZR.png -title_url: https://discord.gg/djPtTRJ +title_url: https://kivy.org/ urls: - - icon: solid/external-link-alt - url: https://kivy.org/ - color: teal - icon: branding/discord url: https://discord.gg/djPtTRJ color: blurple diff --git a/pydis_site/apps/resources/resources/panda3d.yaml b/pydis_site/apps/resources/resources/panda3d.yaml index eeb54465..51861474 100644 --- a/pydis_site/apps/resources/resources/panda3d.yaml +++ b/pydis_site/apps/resources/resources/panda3d.yaml @@ -2,12 +2,9 @@ name: Panda3D description: Panda3D is a Python-focused 3-D framework for rapid development of games, visualizations, and simulations, written in C++ with an emphasis on performance and flexibility. title_image: https://www.panda3d.org/wp-content/uploads/2019/01/panda3d_logo.png -title_url: https://discord.gg/9XsucTT +title_url: https://www.panda3d.org/ position: 9 urls: - - icon: solid/external-link-alt - url: https://www.panda3d.org/ - color: teal - icon: branding/discord url: https://discord.gg/9XsucTT color: blurple diff --git a/pydis_site/apps/resources/resources/people_postgres_data.yaml b/pydis_site/apps/resources/resources/people_postgres_data.yaml index 9fec6634..212eed89 100644 --- a/pydis_site/apps/resources/resources/people_postgres_data.yaml +++ b/pydis_site/apps/resources/resources/people_postgres_data.yaml @@ -5,11 +5,8 @@ description: People, Postgres, Data specializes in building users of Postgres They take a holistic approach to their community inviting not only technical topics but Professional Development and Life in general including movies, games, books and travel. title_image: https://media.discordapp.net/attachments/748954447857844318/750519488268730377/people_postgres_data.png -title_url: https://discord.gg/Ujw8m8v +title_url: https://postgresconf.org/ urls: - - icon: solid/external-link-alt - url: https://postgresconf.org/ - color: teal - icon: branding/discord url: https://discord.gg/Ujw8m8v color: bluple diff --git a/pydis_site/apps/resources/tests/test_resource_data.py b/pydis_site/apps/resources/tests/test_resource_data.py new file mode 100644 index 00000000..3a96e8b9 --- /dev/null +++ b/pydis_site/apps/resources/tests/test_resource_data.py @@ -0,0 +1,25 @@ +import yaml +from django.test import TestCase + +from pydis_site.apps.resources.views import RESOURCES_PATH + + +class TestResourceData(TestCase): + """Test data validity of resources.""" + + def test_no_duplicate_links(self): + """Test that there are no duplicate links in each resource.""" + for path in RESOURCES_PATH.rglob('*.yaml'): + with self.subTest(resource=path.stem): + content = yaml.safe_load(path.read_text()) + url_links = tuple(item['url'] for item in content.get('urls', ())) + if 'title_url' in content: + all_links = url_links + (content['title_url'],) + else: + all_links = url_links + + self.assertCountEqual( + all_links, + set(all_links), + msg="One or more links are duplicated on the resource", + ) -- cgit v1.2.3 From 08307330510fa2d682f28cb0e7d3769d4f462b63 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 24 Jul 2023 10:27:33 +0200 Subject: Pleasure the style dictator --- pydis_site/apps/api/tests/test_infractions.py | 4 ++-- pydis_site/apps/redirect/urls.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index b9b33ff3..f1e54b1e 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -174,14 +174,14 @@ class InfractionTests(AuthenticatedAPITestCase): response = self.client.get(f'{url}?expires_after=gibberish') self.assertEqual(response.status_code, 400) - self.assertEqual(list(response.json())[0], "expires_after") + self.assertEqual(next(iter(response.json())), "expires_after") def test_filter_before_invalid(self): url = reverse('api:bot:infraction-list') response = self.client.get(f'{url}?expires_before=000000000') self.assertEqual(response.status_code, 400) - self.assertEqual(list(response.json())[0], "expires_before") + self.assertEqual(next(iter(response.json())), "expires_before") def test_after_before_before(self): url = reverse('api:bot:infraction-list') diff --git a/pydis_site/apps/redirect/urls.py b/pydis_site/apps/redirect/urls.py index a221ea12..583afa08 100644 --- a/pydis_site/apps/redirect/urls.py +++ b/pydis_site/apps/redirect/urls.py @@ -68,7 +68,7 @@ def map_redirect(name: str, data: Redirect) -> list[URLPattern]: raise ValueError(f"Unknown app in redirect: {new_app_name}") for item in items: - entry = list(item.values())[0] + entry = next(iter(item.values())) # Replace dynamic redirect with concrete path concrete_path = __PARAMETER_REGEX.sub(entry, data.original_path) -- cgit v1.2.3 From d0b792b169e1b70f592f0ab01d4407e04fb6fa49 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 21 Jul 2023 20:50:25 +0200 Subject: Soft fail for upstream error on fetching tag commits Closes #1053 --- pydis_site/apps/content/utils.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index 56f6283d..cfd73d67 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -1,8 +1,10 @@ import datetime import functools import json +import logging import tarfile import tempfile +from http import HTTPStatus from io import BytesIO from pathlib import Path @@ -18,6 +20,7 @@ from pydis_site import settings from .models import Commit, Tag TAG_CACHE_TTL = datetime.timedelta(hours=1) +log = logging.getLogger(__name__) def github_client(**kwargs) -> httpx.Client: @@ -144,9 +147,26 @@ def set_tag_commit(tag: Tag) -> None: # Fetch and set the commit with github_client() as client: - data = client.get("/repos/python-discord/bot/commits", params={"path": path}) - data.raise_for_status() - data = data.json()[0] + response = client.get("/repos/python-discord/bot/commits", params={"path": path}) + if ( + # We want to hop out early in three cases: + # - We got a forbidden response. (GitHub wrongfully uses this for rate limits.) + # - We got ratelimited. + response.status_code in (HTTPStatus.FORBIDDEN, HTTPStatus.TOO_MANY_REQUESTS) + # - GitHub has unicorn time again and is returning 5xx codes. + or int(response.status_code / 100) == 5 + ): # pragma: no cover + log.warning( + "Received code %d from GitHub for commit history for bot file %r", + response.status_code, path, + ) + # We hop out early because otherwise, these failures may result in the + # overall request to the tag page breaking. + return + + # This should only be permanent issues from here, such as bad requests. + response.raise_for_status() + data = response.json()[0] commit = data["commit"] author, committer = commit["author"], commit["committer"] -- cgit v1.2.3 From f4b4528160a48c0fab49c2719c3b1da9e4e0374b Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 21 Jul 2023 20:28:47 +0200 Subject: Clean up pragma intended for Python 3.10 removal --- pydis_site/apps/content/views/page_category.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/views/page_category.py b/pydis_site/apps/content/views/page_category.py index 062c2bc1..1d9a9c39 100644 --- a/pydis_site/apps/content/views/page_category.py +++ b/pydis_site/apps/content/views/page_category.py @@ -56,9 +56,7 @@ class PageOrCategoryView(TemplateView): entry_info["name"] = frontmatter.load(entry).metadata["title"] elif entry.is_dir(): entry_info["name"] = utils.get_category(entry)["title"] - else: # pragma: no cover - # TODO: Remove coverage.py pragma in Python 3.10 - # See: https://github.com/nedbat/coveragepy/issues/198 + else: continue context["subarticles"].append(entry_info) -- cgit v1.2.3 From 982b394ab7c43774adce8b6cd9150615c65b4b55 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 18 Jul 2023 20:05:21 +0200 Subject: Move commit model into its own file Let's stick to the standard scheme - otherwise we might as well just have a single models file. --- pydis_site/apps/content/models/__init__.py | 3 ++- pydis_site/apps/content/models/commit.py | 38 ++++++++++++++++++++++++++++++ pydis_site/apps/content/models/tag.py | 37 +---------------------------- 3 files changed, 41 insertions(+), 37 deletions(-) create mode 100644 pydis_site/apps/content/models/commit.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/models/__init__.py b/pydis_site/apps/content/models/__init__.py index 60007e27..69c48962 100644 --- a/pydis_site/apps/content/models/__init__.py +++ b/pydis_site/apps/content/models/__init__.py @@ -1,3 +1,4 @@ -from .tag import Commit, Tag +from .commit import Commit +from .tag import Tag __all__ = ["Commit", "Tag"] diff --git a/pydis_site/apps/content/models/commit.py b/pydis_site/apps/content/models/commit.py new file mode 100644 index 00000000..4d8bc552 --- /dev/null +++ b/pydis_site/apps/content/models/commit.py @@ -0,0 +1,38 @@ +import collections.abc +import json + +from django.db import models + + +class Commit(models.Model): + """A git commit from the Python Discord Bot project.""" + + URL_BASE = "https://github.com/python-discord/bot/commit/" + + sha = models.CharField( + help_text="The SHA hash of this commit.", + primary_key=True, + max_length=40, + ) + message = models.TextField(help_text="The commit message.") + date = models.DateTimeField(help_text="The date and time the commit was created.") + authors = models.TextField(help_text=( + "The person(s) who created the commit. This is a serialized JSON object. " + "Refer to the GitHub documentation on the commit endpoint " + "(schema/commit.author & schema/commit.committer) for more info. " + "https://docs.github.com/en/rest/commits/commits#get-a-commit" + )) + + @property + def url(self) -> str: + """The URL to the commit on GitHub.""" + return self.URL_BASE + self.sha + + def lines(self) -> collections.abc.Iterable[str]: + """Return each line in the commit message.""" + yield from self.message.split("\n") + + def format_authors(self) -> collections.abc.Iterable[str]: + """Return a nice representation of the author(s)' name and email.""" + for author in json.loads(self.authors): + yield f"{author['name']} <{author['email']}>" diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py index 7c49902f..a28663c5 100644 --- a/pydis_site/apps/content/models/tag.py +++ b/pydis_site/apps/content/models/tag.py @@ -1,41 +1,6 @@ -import collections.abc -import json - from django.db import models - -class Commit(models.Model): - """A git commit from the Python Discord Bot project.""" - - URL_BASE = "https://github.com/python-discord/bot/commit/" - - sha = models.CharField( - help_text="The SHA hash of this commit.", - primary_key=True, - max_length=40, - ) - message = models.TextField(help_text="The commit message.") - date = models.DateTimeField(help_text="The date and time the commit was created.") - authors = models.TextField(help_text=( - "The person(s) who created the commit. This is a serialized JSON object. " - "Refer to the GitHub documentation on the commit endpoint " - "(schema/commit.author & schema/commit.committer) for more info. " - "https://docs.github.com/en/rest/commits/commits#get-a-commit" - )) - - @property - def url(self) -> str: - """The URL to the commit on GitHub.""" - return self.URL_BASE + self.sha - - def lines(self) -> collections.abc.Iterable[str]: - """Return each line in the commit message.""" - yield from self.message.split("\n") - - def format_authors(self) -> collections.abc.Iterable[str]: - """Return a nice representation of the author(s)' name and email.""" - for author in json.loads(self.authors): - yield f"{author['name']} <{author['email']}>" +from .commit import Commit class Tag(models.Model): -- cgit v1.2.3 From 80959ab7af347142203d5d4dbd0ce3c471dfe787 Mon Sep 17 00:00:00 2001 From: NipaDev <60810623+Nipa-Code@users.noreply.github.com> Date: Mon, 31 Jul 2023 20:25:54 +0300 Subject: Fix a typo in bot.md (#1058) Fix a typo in bot.md file. Correct the typo "psotgres" to "postgres". --- .../apps/content/resources/guides/pydis-guides/contributing/bot.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index b5e11a97..f54ee664 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -132,7 +132,7 @@ With all of the above setup, you can run The projec with `docker compose up`. Th Some other useful docker commands are as follows: -1. `docker compose pull` this pulls updates for all non-bot services, such as psotgres, redis and our [site](../site) project! +1. `docker compose pull` this pulls updates for all non-bot services, such as postgres, redis and our [site](../site) project! 1. `docker compose build` this rebuilds the bot's docker image, this is only needed if you need to make changes to the bot's dependencies, or the Dockerfile itself. Your bot is now running, all inside Docker. -- cgit v1.2.3 From 45855fe2e3906614490f3577cb50e530423c35b7 Mon Sep 17 00:00:00 2001 From: jchristgit Date: Wed, 2 Aug 2023 11:06:02 +0200 Subject: Capitalize Python outside of tags (#1046) Related to #1044. Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- .../pydis-guides/contributing/bot-extended-configuration-options.md | 2 +- .../content/resources/guides/python-guides/docker-hosting-guide.md | 2 +- pydis_site/templates/events/pages/code-jams/8/_index.html | 4 ++-- pydis_site/templates/events/pages/code-jams/8/frameworks.html | 2 +- pydis_site/templates/events/pages/code-jams/9/_index.html | 2 +- static-builds/README.md | 2 +- static-builds/netlify_build.py | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md index a35d79a9..f5425d88 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md @@ -73,7 +73,7 @@ If you wish to set all values in your `env.server` for your testing server, you **Note**: This is only required when you're not configuring the bot [automatically](#automatic-configuration) -If you will be working on a feature that includes the python help forum, you will need to use `Forum Channels`. +If you will be working on a feature that includes the Python help forum, you will need to use `Forum Channels`. Forum channels cannot be included in a template, which is why this needs to be done by hand for the time being. diff --git a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md index 57d86e99..7af6a0bb 100644 --- a/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -26,7 +26,7 @@ description: This guide shows how to host a bot with Docker and GitHub Actions o ## Introduction -Let's say you have got a nice discord bot written in python and you have a VPS to host it on. Now the only question is +Let's say you have got a nice discord bot written in Python and you have a VPS to host it on. Now the only question is how to run it 24/7. You might have been suggested to use *screen multiplexer*, but it has some disadvantages: 1. Every time you update the bot you have to SSH to your server, attach to screen, shutdown the bot, run `git pull` and diff --git a/pydis_site/templates/events/pages/code-jams/8/_index.html b/pydis_site/templates/events/pages/code-jams/8/_index.html index 628a2c22..74e0ad4a 100644 --- a/pydis_site/templates/events/pages/code-jams/8/_index.html +++ b/pydis_site/templates/events/pages/code-jams/8/_index.html @@ -26,7 +26,7 @@

      Perceptive Porcupines: WTPython!?

      VV, Poppinawhile, ethansocal, Jeff Z, Cohan, ¯\_(ツ)_/¯

      - What the Python (wtpython) is a simple terminal user interface that allows you to explore relevant answers on Stackoverflow without leaving your terminal or IDE. When you get an error, all you have to do is swap python for wtpython. When your code hits an error, you'll see a textual interface for exploring relevant answers allowing you to stay focused and ship faster! + What the Python (wtpython) is a simple terminal user interface that allows you to explore relevant answers on Stackoverflow without leaving your terminal or IDE. When you get an error, all you have to do is swap Python for wtpython. When your code hits an error, you'll see a textual interface for exploring relevant answers allowing you to stay focused and ship faster!

      Demo video @@ -89,7 +89,7 @@

      The Qualifier

      The qualifier is a coding challenge that you are required to complete before registering for the code jam. - This is meant as a basic assessment of your skills to ensure you have enough python knowledge to effectively contribute in a team environment. + This is meant as a basic assessment of your skills to ensure you have enough Python knowledge to effectively contribute in a team environment.

      View the Qualifier

      diff --git a/pydis_site/templates/events/pages/code-jams/8/frameworks.html b/pydis_site/templates/events/pages/code-jams/8/frameworks.html index 1c02e38a..d8fbe96f 100644 --- a/pydis_site/templates/events/pages/code-jams/8/frameworks.html +++ b/pydis_site/templates/events/pages/code-jams/8/frameworks.html @@ -103,7 +103,7 @@

      • Documentation Link
      • Supports: Linux, Mac, and Windows
      • -
      • Pure python library
      • +
      • Pure Python library
      diff --git a/pydis_site/templates/events/pages/code-jams/9/_index.html b/pydis_site/templates/events/pages/code-jams/9/_index.html index 3e60c387..5c094b22 100644 --- a/pydis_site/templates/events/pages/code-jams/9/_index.html +++ b/pydis_site/templates/events/pages/code-jams/9/_index.html @@ -75,7 +75,7 @@

      The Qualifier

      The qualifier is a coding challenge that you are required to complete before registering for the code jam. - This is meant as a basic assessment of your skills to ensure you have enough python knowledge to effectively contribute in a team environment. + This is meant as a basic assessment of your skills to ensure you have enough Python knowledge to effectively contribute in a team environment.

      View the Qualifier

      diff --git a/static-builds/README.md b/static-builds/README.md index a3c7962b..afd34bb0 100644 --- a/static-builds/README.md +++ b/static-builds/README.md @@ -42,7 +42,7 @@ Publish Directory: | Name | Value | Description | |----------------|--------------------------------|-------------------------------------------------------------------------------------------| -| PYTHON_VERSION | 3.8 | The python version. Supported options are defined by netlify [here][netlify build image]. | +| PYTHON_VERSION | 3.8 | The Python version. Supported options are defined by netlify [here][netlify build image]. | | API_URL | https://pythondiscord.com/ | The link to the API, which will be used to fetch the build artifacts. | | ACTION_NAME | Build & Publish Static Preview | The name of the workflow which will be used to find the artifact. | | ARTIFACT_NAME | static-build | The name of the artifact to download. | diff --git a/static-builds/netlify_build.py b/static-builds/netlify_build.py index 2d311a11..4d4a613d 100644 --- a/static-builds/netlify_build.py +++ b/static-builds/netlify_build.py @@ -1,6 +1,6 @@ """Build script to deploy project on netlify.""" -# WARNING: This file must remain compatible with python 3.8 +# WARNING: This file must remain compatible with Python 3.8 # This script performs all the actions required to build and deploy our project on netlify # It depends on the following packages, which are set in the netlify UI: -- cgit v1.2.3 From 36e841176efe46292ee64c2610b619c3c9c65f8b Mon Sep 17 00:00:00 2001 From: Anonymous Date: Fri, 11 Aug 2023 22:42:41 -0500 Subject: Add "pay" alias for rule tag --- pydis_site/apps/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index f155aa0b..05c15394 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -169,7 +169,7 @@ class RulesView(APIView): ), ( "Do not offer or ask for paid work of any kind.", - ["paid", "work", "money"] + ["pay", "paid", "work", "money"] ), ( "Do not copy and paste answers from ChatGPT or similar AI tools.", -- cgit v1.2.3 From 0e389069035af8bbbc6450341373ad18b53e575a Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 31 Aug 2023 09:18:10 +0200 Subject: Document Netlify static previews Closes #677. --- .../content/resources/guides/pydis-guides/contributing/site.md | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md index 9eef8d23..13021e65 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md @@ -128,6 +128,14 @@ Unless you are editing the Dockerfile or docker-compose.yml, you shouldn't need [**Click here to see the basic Git workflow when contributing to one of our projects.**](../working-with-git/) +--- +# Deploy previews + +When you open a pull request, the `netlify` bot will build and publish a static +preview of your changes, which is very valuable if you made any changes to the +content or styling of the website. An example deploy preview can be found on +[pull request #773](https://github.com/python-discord/site/pull/773#issuecomment-1257224147). + --- # Django admin site -- cgit v1.2.3 From fd3c75be589f9d622a222ebbd017da503df6ffe2 Mon Sep 17 00:00:00 2001 From: jchristgit Date: Mon, 18 Sep 2023 11:54:15 +0200 Subject: Link guide to contributing a page in site guide (#1105) --- .../apps/content/resources/guides/pydis-guides/contributing/site.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md index 13021e65..b1ed54b8 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md @@ -181,6 +181,6 @@ The website is configured through the following environment variables: # Next steps Now that you have everything setup, it is finally time to make changes to the site! If you have not yet read the [contributing guidelines](../contributing-guidelines.md), now is a good time. Contributions that do not adhere to the guidelines may be rejected. -If you're not sure where to go from here, our [detailed walkthrough](../#2-set-up-the-project) is for you. +If you're not sure where to go from here, our [detailed walkthrough](../#2-set-up-the-project), or the [guide on contributing a page](../../how-to-contribute-a-page) is for you. Have fun! -- cgit v1.2.3 From be6b69824fbcfdc1c73f17eefa31e81f04b704a3 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 19 Sep 2023 11:29:39 +0100 Subject: Remove the redirect FilterList on migration This redirect filter list does not have any filters, nor does it have a bot implementation. It is something that we were working on, but has been stalled for a while. The presence of this filter list also causes the bot to warn on startup due to it finding a filter list with no implementation. This commit removes the FilterList, which can be added back if/when we support this filter type in bot. --- .../api/migrations/0092_remove_redirect_filter_list.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 pydis_site/apps/api/migrations/0092_remove_redirect_filter_list.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/migrations/0092_remove_redirect_filter_list.py b/pydis_site/apps/api/migrations/0092_remove_redirect_filter_list.py new file mode 100644 index 00000000..69dd99d0 --- /dev/null +++ b/pydis_site/apps/api/migrations/0092_remove_redirect_filter_list.py @@ -0,0 +1,16 @@ +from django.db import migrations +from django.apps.registry import Apps +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def forward(apps: Apps, _: BaseDatabaseSchemaEditor) -> None: + apps.get_model("api", "FilterList").objects.filter(name="redirect").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0091_antispam_filter_list"), + ] + + operations = [migrations.RunPython(forward)] -- cgit v1.2.3 From b1b5dc41dffae89602fd3a8d8be8efcd661ebd73 Mon Sep 17 00:00:00 2001 From: jchristgit Date: Wed, 20 Sep 2023 09:10:37 +0200 Subject: Merge pull request #1104 from python-discord/no-more-previews-for-redirects Links have been removed as part of #681. Closes #680. --- pydis_site/apps/redirect/urls.py | 78 +++++----------------------------------- 1 file changed, 8 insertions(+), 70 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/redirect/urls.py b/pydis_site/apps/redirect/urls.py index 583afa08..ca8b379d 100644 --- a/pydis_site/apps/redirect/urls.py +++ b/pydis_site/apps/redirect/urls.py @@ -3,14 +3,9 @@ import re import yaml from django import conf -from django.http import HttpResponse from django.urls import URLPattern, path -from django_distill import distill_path -from pydis_site import settings -from pydis_site.apps.content import urls as pages_urls from pydis_site.apps.redirect.views import CustomRedirectView -from pydis_site.apps.resources import urls as resources_urls app_name = "redirect" @@ -31,72 +26,15 @@ class Redirect: def map_redirect(name: str, data: Redirect) -> list[URLPattern]: - """Return a pattern using the Redirects app, or a static HTML redirect for static builds.""" - if not settings.STATIC_BUILD: - # Normal dynamic redirect - return [path( - data.original_path, - CustomRedirectView.as_view( - pattern_name=data.redirect_route, - static_args=tuple(data.redirect_arguments), - prefix_redirect=data.prefix_redirect - ), - name=name - )] - - # Create static HTML redirects for static builds - new_app_name = data.redirect_route.split(":")[0] - - if __PARAMETER_REGEX.search(data.original_path): - # Redirects for paths which accept parameters - # We generate an HTML redirect file for all possible entries - paths = [] - - class RedirectFunc: - def __init__(self, new_url: str, _name: str): - self.result = HttpResponse(REDIRECT_TEMPLATE.format(url=new_url)) - self.__qualname__ = _name - - def __call__(self, *args, **kwargs): - return self.result - - if new_app_name == resources_urls.app_name: - items = resources_urls.get_all_resources() - elif new_app_name == pages_urls.app_name: - items = pages_urls.get_all_pages() - else: - raise ValueError(f"Unknown app in redirect: {new_app_name}") - - for item in items: - entry = next(iter(item.values())) - - # Replace dynamic redirect with concrete path - concrete_path = __PARAMETER_REGEX.sub(entry, data.original_path) - new_redirect = f"/{new_app_name}/{entry}" - pattern_name = f"{name}_{entry}" - - paths.append(distill_path( - concrete_path, - RedirectFunc(new_redirect, pattern_name), - name=pattern_name - )) - - return paths - - redirect_path_name = "pages" if new_app_name == "content" else new_app_name - if len(data.redirect_arguments) > 0: - redirect_arg = data.redirect_arguments[0] - else: - redirect_arg = "resources/" - new_redirect = f"/{redirect_path_name}/{redirect_arg}" - - if new_redirect == "/resources/resources/": - new_redirect = "/resources/" - - return [distill_path( + """Return a pattern using the Redirects app.""" + return [path( data.original_path, - lambda *args: HttpResponse(REDIRECT_TEMPLATE.format(url=new_redirect)), - name=name, + CustomRedirectView.as_view( + pattern_name=data.redirect_route, + static_args=tuple(data.redirect_arguments), + prefix_redirect=data.prefix_redirect + ), + name=name )] -- cgit v1.2.3 From 83fd70647cebdccf23e892c3d462c613e3674784 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 4 Dec 2023 10:26:23 +0100 Subject: Update required Python version Include a note to update the contributor guide in the Dockerfile instruction pinning the Python version as well. --- Dockerfile | 4 ++++ .../apps/content/resources/guides/pydis-guides/contributing/site.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/Dockerfile b/Dockerfile index 448d81cf..5aea6dbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,7 @@ +# When updating the Python version here, please make sure to also +# update the contributor guide, which can be found at +# pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +# Thank you! FROM ghcr.io/owl-corp/python-poetry-base:3.11-slim # Allow service to handle stops gracefully diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md index b1ed54b8..2515d07f 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md @@ -9,7 +9,7 @@ You should have already forked the [`site`](https://github.com/python-discord/si ### Requirements -- [Python 3.10](https://www.python.org/downloads/) +- [Python 3.11](https://www.python.org/downloads/) - [Poetry](https://python-poetry.org/docs/#installation) - `pip install poetry` - [Git](https://git-scm.com/downloads) -- cgit v1.2.3 From dcb3e14ab19a20fd10eeea0e5ff1736e2c616e61 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 4 Dec 2023 16:26:45 +0100 Subject: Fix typo in psql quit command --- .../apps/content/resources/guides/pydis-guides/contributing/site.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md index 2515d07f..404ff429 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md @@ -53,7 +53,7 @@ CREATE DATABASE pysite WITH OWNER pysite; CREATE DATABASE metricity WITH OWNER pysite; ``` -Finally, enter `/q` to exit psql. +Finally, enter `\q` to exit psql. ### 2. Environment variables -- cgit v1.2.3 From 949ddaf9d06a9559679615d2ca63a591e6dc4e90 Mon Sep 17 00:00:00 2001 From: ~hedy Date: Fri, 8 Dec 2023 21:07:31 +0800 Subject: Add alias "hire" for rule 9 ref: https://discord.com/channels/267624335836053506/429409067623251969/1182407804787626047 --- pydis_site/apps/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 05c15394..829086e7 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -169,7 +169,7 @@ class RulesView(APIView): ), ( "Do not offer or ask for paid work of any kind.", - ["pay", "paid", "work", "money"] + ["pay", "paid", "work", "money", "hire"] ), ( "Do not copy and paste answers from ChatGPT or similar AI tools.", -- cgit v1.2.3 From 182c1356833d30f77bd1b83b138d7e5e57d63dd6 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 10 Dec 2023 15:39:11 +0100 Subject: Implement the github-filter worker in the API The current github-filter worker, found at https://github.com/python-discord/workers/blob/main/github-filter/src/index.ts, fails to work at present because Discord's webhook endpoints block Cloudflare's IP ranges from accessing this endpoint. Whilst they use Cloudflare to guard themselves, it seems they do not wish others to use it. Implement it on the site to circumvent IP restrictions and allow to modify the code in Python. --- .../apps/api/tests/test_github_webhook_filter.py | 46 +++++++++ pydis_site/apps/api/urls.py | 12 ++- pydis_site/apps/api/views.py | 104 +++++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 pydis_site/apps/api/tests/test_github_webhook_filter.py (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_github_webhook_filter.py b/pydis_site/apps/api/tests/test_github_webhook_filter.py new file mode 100644 index 00000000..2c9f59e5 --- /dev/null +++ b/pydis_site/apps/api/tests/test_github_webhook_filter.py @@ -0,0 +1,46 @@ +from unittest import mock + +from django.urls import reverse +from rest_framework.test import APITestCase + + +class GitHubWebhookFilterAPITests(APITestCase): + def test_ignores_bot_sender(self): + url = reverse('api:github-webhook-filter', args=('id', 'token')) + payload = {'sender': {'login': 'limette', 'type': 'bot'}} + headers = {'X-GitHub-Event': 'pull_request_review'} + response = self.client.post(url, data=payload, headers=headers) + self.assertEqual(response.status_code, 203) + + def test_accepts_interesting_events(self): + url = reverse('api:github-webhook-filter', args=('id', 'token')) + payload = { + 'ref': 'refs/heads/master', + 'pull_request': { + 'user': { + 'login': "lemon", + } + }, + 'review': { + 'state': 'commented', + 'body': "Amazing!!!" + }, + 'repository': { + 'name': 'black', + 'owner': { + 'login': 'psf', + } + } + } + headers = {'X-GitHub-Event': 'pull_request_review'} + + with mock.patch('urllib.request.urlopen') as urlopen: + urlopen.return_value = mock.MagicMock() + context_mock = urlopen.return_value.__enter__.return_value + context_mock.status = 299 + context_mock.getheaders.return_value = [('X-Clacks-Overhead', 'Joe Armstrong')] + context_mock.read.return_value = b'{"status": "ok"}' + + response = self.client.post(url, data=payload, headers=headers) + self.assertEqual(response.status_code, context_mock.status) + self.assertEqual(response.headers.get('X-Clacks-Overhead'), 'Joe Armstrong') diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index f872ba92..80d4edc2 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -1,7 +1,12 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import GitHubArtifactsView, HealthcheckView, RulesView +from .views import ( + GitHubArtifactsView, + GitHubWebhookFilterView, + HealthcheckView, + RulesView, +) from .viewsets import ( AocAccountLinkViewSet, AocCompletionistBlockViewSet, @@ -101,4 +106,9 @@ urlpatterns = ( GitHubArtifactsView.as_view(), name="github-artifacts" ), + path( + 'github/webhook-filter//', + GitHubWebhookFilterView.as_view(), + name='github-webhook-filter' + ), ) diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 829086e7..8a9eebd7 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -1,3 +1,8 @@ +import json +import urllib.request +from collections.abc import Mapping + +from rest_framework import status from rest_framework.exceptions import ParseError from rest_framework.request import Request from rest_framework.response import Response @@ -226,3 +231,102 @@ class GitHubArtifactsView(APIView): "error": str(e), "requested_resource": f"{owner}/{repo}/{sha}/{action_name}/{artifact_name}" }, status=e.status) + + +class GitHubWebhookFilterView(APIView): + """ + Filters uninteresting events from webhooks sent by GitHub to Discord. + + ## Routes + ### POST /github/webhook-filter/:webhook_id/:webhook_token + Takes the GitHub webhook payload as the request body, documented on here: + https://docs.github.com/en/webhooks/webhook-events-and-payloads. The endpoint + will then determine whether the sent webhook event is of interest, + and if so, will forward it to Discord. The response from Discord is + then returned back to the client of this website, including the original + status code and headers (excluding `Content-Type`). + + ## Authentication + Does not require any authentication nor permissions on its own, however, + Discord will validate that the webhook originates from GitHub and respond + with a 403 forbidden error if not. + """ + + authentication_classes = () + permission_classes = () + + def post(self, request: Request, *, webhook_id: str, webhook_token: str) -> Response: + """Filter a webhook POST from GitHub before sending it to Discord.""" + sender = request.data.get('sender', {}) + sender_name = sender.get('login', '') + event = request.headers.get('X-GitHub-Event') + repository = request.data.get('repository', {}) + + is_coveralls = 'coveralls' in sender_name + is_github_bot = sender.get('type') == 'bot' + is_sentry = 'sentry-io' in sender_name + is_dependabot_branch_deletion = ( + 'dependabot' in request.data.get('ref', '') + and event == 'delete' + ) + is_bot_pr_approval = ( + '[bot]' in request.data.get('pull_request', {}).get('user', {}).get('login', '') + and event == 'pull_request_review' + ) + is_empty_review = ( + request.data.get('review', {}).get('state') == 'commented' + and event == 'pull_request_review' + and request.data.get('review', {}).get('body') is None + ) + is_black_non_main_push = ( + request.data.get('ref') != 'refs/heads/main' + and repository.get('name') == 'black' + and repository.get('owner', {}).get('login') == 'psf' + and event == 'push' + ) + + is_bot_payload = ( + is_coveralls + or (is_github_bot and not is_sentry) + or is_dependabot_branch_deletion + or is_bot_pr_approval + ) + is_noisy_user_action = is_empty_review + should_ignore = is_bot_payload or is_noisy_user_action or is_black_non_main_push + + if should_ignore: + return Response( + {'message': "Ignored by github-filter endpoint"}, + status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION, + ) + + (response_status, headers, body) = self.send_webhook( + webhook_id, webhook_token, request.data, dict(request.headers), + ) + headers.pop('Connection', None) + headers.pop('Content-Length', None) + return Response(data=body, headers=headers, status=response_status) + + def send_webhook( + self, + webhook_id: str, + webhook_token: str, + data: dict, + headers: Mapping[str, str], + ) -> tuple[int, dict[str, str], bytes]: + """Execute a webhook on Discord's GitHub webhook endpoint.""" + payload = json.dumps(data).encode() + headers.pop('Content-Length', None) + headers.pop('Content-Type', None) + headers.pop('Host', None) + request = urllib.request.Request( # noqa: S310 + f'https://discord.com/api/webhooks/{webhook_id}/{webhook_token}/github?wait=1', + data=payload, + headers={'Content-Type': 'application/json', **headers}, + ) + + try: + with urllib.request.urlopen(request) as response: # noqa: S310 + return (response.status, dict(response.getheaders()), response.read()) + except urllib.error.HTTPError as err: # pragma: no cover + return (err.code, dict(err.headers), err.fp.read()) -- cgit v1.2.3 From f33d3535be5eb1bad6df1d765872b6db05b5fff3 Mon Sep 17 00:00:00 2001 From: jchristgit Date: Mon, 11 Dec 2023 14:00:08 +0100 Subject: Mention READMEs for app dirs in contributing guide (#1168) Allow people to figure out our repository structure easily after settnig up the project. Part of #673. --- .../apps/content/resources/guides/pydis-guides/contributing/site.md | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md index 404ff429..1927f449 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md @@ -183,4 +183,9 @@ Now that you have everything setup, it is finally time to make changes to the si If you're not sure where to go from here, our [detailed walkthrough](../#2-set-up-the-project), or the [guide on contributing a page](../../how-to-contribute-a-page) is for you. +The site repository also contains `README.md` files in all major directories of +interest, which explain where which functionality of the site is located. For +example, see the [API app's +README](https://github.com/python-discord/site/tree/main/pydis_site/apps/api). + Have fun! -- cgit v1.2.3 From f64174645de751075a00380c292b2eea96c687c4 Mon Sep 17 00:00:00 2001 From: jchristgit Date: Mon, 11 Dec 2023 16:33:16 +0100 Subject: Implement editing of offensive message records (#1165) Allow changing the deletion date of offensive message records in case the bot encounters an error during deletion attempts. Fixes #364. Unblocks python-discord/bot#1013. --- .../apps/api/tests/test_offensive_message.py | 68 +++++++++++++++------- .../apps/api/viewsets/bot/offensive_message.py | 13 ++++- 2 files changed, 59 insertions(+), 22 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_offensive_message.py b/pydis_site/apps/api/tests/test_offensive_message.py index f45b5a66..d01231f1 100644 --- a/pydis_site/apps/api/tests/test_offensive_message.py +++ b/pydis_site/apps/api/tests/test_offensive_message.py @@ -6,6 +6,17 @@ from .base import AuthenticatedAPITestCase from pydis_site.apps.api.models import OffensiveMessage +def create_offensive_message() -> OffensiveMessage: + """Creates and returns an `OffensiveMessgage` record for tests.""" + delete_at = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1) + + return OffensiveMessage.objects.create( + id=602951077675139072, + channel_id=291284109232308226, + delete_date=delete_at, + ) + + class CreationTests(AuthenticatedAPITestCase): def test_accept_valid_data(self): url = reverse('api:bot:offensivemessage-list') @@ -111,13 +122,7 @@ class ListTests(AuthenticatedAPITestCase): class DeletionTests(AuthenticatedAPITestCase): @classmethod def setUpTestData(cls): - delete_at = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1) - - cls.valid_offensive_message = OffensiveMessage.objects.create( - id=602951077675139072, - channel_id=291284109232308226, - delete_date=delete_at.isoformat() - ) + cls.valid_offensive_message = create_offensive_message() def test_delete_data(self): url = reverse( @@ -132,24 +137,45 @@ class DeletionTests(AuthenticatedAPITestCase): ) -class NotAllowedMethodsTests(AuthenticatedAPITestCase): +class UpdateOffensiveMessageTestCase(AuthenticatedAPITestCase): @classmethod def setUpTestData(cls): - delete_at = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1) + cls.message = create_offensive_message() + cls.in_one_week = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=7) - cls.valid_offensive_message = OffensiveMessage.objects.create( - id=602951077675139072, - channel_id=291284109232308226, - delete_date=delete_at.isoformat() + def test_updating_message(self): + url = reverse('api:bot:offensivemessage-detail', args=(self.message.id,)) + data = {'delete_date': self.in_one_week.isoformat()} + update_response = self.client.patch(url, data=data) + self.assertEqual(update_response.status_code, 200) + + self.message.refresh_from_db() + self.assertAlmostEqual( + self.message.delete_date, + self.in_one_week, + delta=datetime.timedelta(seconds=1), ) - def test_returns_405_for_patch_and_put_requests(self): - url = reverse( - 'api:bot:offensivemessage-detail', args=(self.valid_offensive_message.id,) + def test_updating_nonexistent_message(self): + url = reverse('api:bot:offensivemessage-detail', args=(self.message.id + 1,)) + data = {'delete_date': self.in_one_week} + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 404) + self.message.refresh_from_db() + self.assertNotAlmostEqual( + self.message.delete_date, + self.in_one_week, + delta=datetime.timedelta(seconds=1), ) - not_allowed_methods = (self.client.patch, self.client.put) - for method in not_allowed_methods: - with self.subTest(method=method): - response = method(url, {}) - self.assertEqual(response.status_code, 405) + +class NotAllowedMethodsTests(AuthenticatedAPITestCase): + @classmethod + def setUpTestData(cls): + cls.message = create_offensive_message() + + def test_returns_405_for_get(self): + url = reverse('api:bot:offensivemessage-detail', args=(self.message.id,)) + response = self.client.get(url) + self.assertEqual(response.status_code, 405) diff --git a/pydis_site/apps/api/viewsets/bot/offensive_message.py b/pydis_site/apps/api/viewsets/bot/offensive_message.py index 54cb3a38..fc8837e0 100644 --- a/pydis_site/apps/api/viewsets/bot/offensive_message.py +++ b/pydis_site/apps/api/viewsets/bot/offensive_message.py @@ -1,6 +1,7 @@ from rest_framework.mixins import ( CreateModelMixin, DestroyModelMixin, + UpdateModelMixin, ListModelMixin ) from rest_framework.viewsets import GenericViewSet @@ -10,7 +11,7 @@ from pydis_site.apps.api.serializers import OffensiveMessageSerializer class OffensiveMessageViewSet( - CreateModelMixin, ListModelMixin, DestroyModelMixin, GenericViewSet + CreateModelMixin, ListModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet ): """ View providing CRUD access to offensive messages. @@ -46,6 +47,16 @@ class OffensiveMessageViewSet( - 201: returned on success - 400: if the body format is invalid + ### PATCH /bot/offensive-messages/ + Perform a partial update of the offensive message with the given `id`. + Intended to allow rescheduling the deletion date in case the bot's attempt + to delete the message failed due to another error than the message already + being deleted. + + #### Status codes + - 200: returned on success + - 404: if a offensive message object with the given `id` does not exist + ### DELETE /bot/offensive-messages/ Delete the offensive message object with the given `id`. -- cgit v1.2.3 From e8f8161e41a4735897b3038e202107f5d55ec96e Mon Sep 17 00:00:00 2001 From: jchristgit Date: Mon, 11 Dec 2023 18:09:39 +0100 Subject: Unify frozen fields logic into serializer mixin (#1169) Additionally, implement frozen fields on the offensive message serializer. --- pydis_site/apps/api/serializers.py | 34 ++++++++++++++++++++-- pydis_site/apps/api/tests/test_nominations.py | 2 +- .../apps/api/tests/test_offensive_message.py | 8 +++++ pydis_site/apps/api/viewsets/bot/infraction.py | 5 ---- pydis_site/apps/api/viewsets/bot/nomination.py | 5 ---- 5 files changed, 40 insertions(+), 14 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 2186b02c..87fd6190 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -2,6 +2,7 @@ from datetime import timedelta from typing import Any +from django.db import models from django.db.models.query import QuerySet from django.db.utils import IntegrityError from rest_framework.exceptions import NotFound @@ -35,6 +36,30 @@ from .models import ( User ) +class FrozenFieldsMixin: + """ + Serializer mixin that allows adding non-updateable fields to a serializer. + + To use, inherit from the mixin and specify the fields that should only be + written to on creation in the `frozen_fields` attribute of the `Meta` class + in a serializer. + + See also the DRF discussion for this feature at + https://github.com/encode/django-rest-framework/discussions/8606, which may + eventually provide an official way to implement this. + """ + + def update(self, instance: models.Model, validated_data: dict) -> models.Model: + """Validate that no frozen fields were changed and update the instance.""" + for field_name in getattr(self.Meta, 'frozen_fields', ()): + if field_name in validated_data: + raise ValidationError( + { + field_name: ["This field cannot be updated."] + } + ) + return super().update(instance, validated_data) + class BotSettingSerializer(ModelSerializer): """A class providing (de-)serialization of `BotSetting` instances.""" @@ -426,7 +451,7 @@ class FilterListSerializer(ModelSerializer): # endregion -class InfractionSerializer(ModelSerializer): +class InfractionSerializer(FrozenFieldsMixin, ModelSerializer): """A class providing (de-)serialization of `Infraction` instances.""" class Meta: @@ -447,6 +472,7 @@ class InfractionSerializer(ModelSerializer): 'dm_sent', 'jump_url' ) + frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden') def validate(self, attrs: dict) -> dict: """Validate data constraints for the given data and abort if it is invalid.""" @@ -683,7 +709,7 @@ class NominationEntrySerializer(ModelSerializer): fields = ('nomination', 'actor', 'reason', 'inserted_at') -class NominationSerializer(ModelSerializer): +class NominationSerializer(FrozenFieldsMixin, ModelSerializer): """A class providing (de-)serialization of `Nomination` instances.""" entries = NominationEntrySerializer(many=True, read_only=True) @@ -703,9 +729,10 @@ class NominationSerializer(ModelSerializer): 'entries', 'thread_id' ) + frozen_fields = ('id', 'inserted_at', 'user', 'ended_at') -class OffensiveMessageSerializer(ModelSerializer): +class OffensiveMessageSerializer(FrozenFieldsMixin, ModelSerializer): """A class providing (de-)serialization of `OffensiveMessage` instances.""" class Meta: @@ -713,3 +740,4 @@ class OffensiveMessageSerializer(ModelSerializer): model = OffensiveMessage fields = ('id', 'channel_id', 'delete_date') + frozen_fields = ('id', 'channel_id') diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py index 7fe2f0a8..e4dfe36a 100644 --- a/pydis_site/apps/api/tests/test_nominations.py +++ b/pydis_site/apps/api/tests/test_nominations.py @@ -254,7 +254,7 @@ class NominationTests(AuthenticatedAPITestCase): def test_returns_400_on_frozen_field_update(self): url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,)) data = { - 'user': "Theo Katzman" + 'user': 1234 } response = self.client.patch(url, data=data) diff --git a/pydis_site/apps/api/tests/test_offensive_message.py b/pydis_site/apps/api/tests/test_offensive_message.py index d01231f1..2dc60bc3 100644 --- a/pydis_site/apps/api/tests/test_offensive_message.py +++ b/pydis_site/apps/api/tests/test_offensive_message.py @@ -156,6 +156,14 @@ class UpdateOffensiveMessageTestCase(AuthenticatedAPITestCase): delta=datetime.timedelta(seconds=1), ) + def test_updating_write_once_fields(self): + """Fields such as the channel ID may not be updated.""" + url = reverse('api:bot:offensivemessage-detail', args=(self.message.id,)) + data = {'channel_id': self.message.channel_id + 1} + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {'channel_id': ["This field cannot be updated."]}) + def test_updating_nonexistent_message(self): url = reverse('api:bot:offensivemessage-detail', args=(self.message.id + 1,)) data = {'delete_date': self.in_one_week} diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index 26cae3ad..09c05a74 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -157,14 +157,9 @@ class InfractionViewSet( filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter) filterset_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type') search_fields = ('$reason',) - frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden') def partial_update(self, request: HttpRequest, *_args, **_kwargs) -> Response: """Method that handles the nuts and bolts of updating an Infraction.""" - for field in request.data: - if field in self.frozen_fields: - raise ValidationError({field: ['This field cannot be updated.']}) - instance = self.get_object() serializer = self.get_serializer(instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) diff --git a/pydis_site/apps/api/viewsets/bot/nomination.py b/pydis_site/apps/api/viewsets/bot/nomination.py index 78687e0e..953513e0 100644 --- a/pydis_site/apps/api/viewsets/bot/nomination.py +++ b/pydis_site/apps/api/viewsets/bot/nomination.py @@ -173,7 +173,6 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge queryset = Nomination.objects.all() filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter) filterset_fields = ('user__id', 'active') - frozen_fields = ('id', 'inserted_at', 'user', 'ended_at') frozen_on_create = ('ended_at', 'end_reason', 'active', 'inserted_at', 'reviewed') def create(self, request: HttpRequest, *args, **kwargs) -> Response: @@ -238,10 +237,6 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge Called by the Django Rest Framework in response to the corresponding HTTP request. """ - for field in request.data: - if field in self.frozen_fields: - raise ValidationError({field: ['This field cannot be updated.']}) - instance = self.get_object() serializer = self.get_serializer(instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) -- cgit v1.2.3 From e50093d47fffec76bd89d5a18bbec47b50558cf4 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 11 Dec 2023 16:59:48 +0000 Subject: Log a warning when being ratelimtted by Github --- pydis_site/apps/api/tests/test_github_webhook_filter.py | 16 ++++++++++++++++ pydis_site/apps/api/views.py | 9 +++++++++ 2 files changed, 25 insertions(+) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/tests/test_github_webhook_filter.py b/pydis_site/apps/api/tests/test_github_webhook_filter.py index 2c9f59e5..8ca60511 100644 --- a/pydis_site/apps/api/tests/test_github_webhook_filter.py +++ b/pydis_site/apps/api/tests/test_github_webhook_filter.py @@ -1,8 +1,10 @@ from unittest import mock +from urllib.error import HTTPError from django.urls import reverse from rest_framework.test import APITestCase +from pydis_site.apps.api.views import GitHubWebhookFilterView class GitHubWebhookFilterAPITests(APITestCase): def test_ignores_bot_sender(self): @@ -44,3 +46,17 @@ class GitHubWebhookFilterAPITests(APITestCase): response = self.client.post(url, data=payload, headers=headers) self.assertEqual(response.status_code, context_mock.status) self.assertEqual(response.headers.get('X-Clacks-Overhead'), 'Joe Armstrong') + + def test_rate_limit_is_logged_to_sentry(self): + url = reverse('api:github-webhook-filter', args=('id', 'token')) + payload = {} + headers = {'X-GitHub-Event': 'pull_request_review'} + with ( + mock.patch('urllib.request.urlopen') as urlopen, + mock.patch.object(GitHubWebhookFilterView, "logger") as logger, + ): + urlopen.side_effect = HTTPError(None, 429, 'Too Many Requests', {}, None) + logger.warning = mock.PropertyMock() + self.client.post(url, data=payload, headers=headers) + + logger.warning.assert_called_once() diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 8a9eebd7..9b0974f3 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -1,6 +1,8 @@ import json +import logging import urllib.request from collections.abc import Mapping +from http import HTTPStatus from rest_framework import status from rest_framework.exceptions import ParseError @@ -254,6 +256,7 @@ class GitHubWebhookFilterView(APIView): authentication_classes = () permission_classes = () + logger = logging.getLogger(__name__ + ".GitHubWebhookFilterView") def post(self, request: Request, *, webhook_id: str, webhook_token: str) -> Response: """Filter a webhook POST from GitHub before sending it to Discord.""" @@ -329,4 +332,10 @@ class GitHubWebhookFilterView(APIView): with urllib.request.urlopen(request) as response: # noqa: S310 return (response.status, dict(response.getheaders()), response.read()) except urllib.error.HTTPError as err: # pragma: no cover + if err.code == HTTPStatus.TOO_MANY_REQUESTS: + self.logger.warning( + "We are being rate limited by Discord! Scope: %s, reset-after: %s", + headers.get("X-RateLimit-Scope"), + headers.get("X-RateLimit-Reset-After"), + ) return (err.code, dict(err.headers), err.fp.read()) -- cgit v1.2.3 From c7e1c562de989cb7a4b7bce19a15de557c58743b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 12 Dec 2023 15:45:43 +0000 Subject: Convert to lower case before checking equality --- pydis_site/apps/api/views.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 9b0974f3..20c7536c 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -261,30 +261,30 @@ class GitHubWebhookFilterView(APIView): def post(self, request: Request, *, webhook_id: str, webhook_token: str) -> Response: """Filter a webhook POST from GitHub before sending it to Discord.""" sender = request.data.get('sender', {}) - sender_name = sender.get('login', '') - event = request.headers.get('X-GitHub-Event') + sender_name = sender.get('login', '').lower() + event = request.headers.get('X-GitHub-Event', '').lower() repository = request.data.get('repository', {}) is_coveralls = 'coveralls' in sender_name - is_github_bot = sender.get('type') == 'bot' + is_github_bot = sender.get('type', '').lower() == 'bot' is_sentry = 'sentry-io' in sender_name is_dependabot_branch_deletion = ( - 'dependabot' in request.data.get('ref', '') + 'dependabot' in request.data.get('ref', '').lower() and event == 'delete' ) is_bot_pr_approval = ( - '[bot]' in request.data.get('pull_request', {}).get('user', {}).get('login', '') + '[bot]' in request.data.get('pull_request', {}).get('user', {}).get('login', '').lower() and event == 'pull_request_review' ) is_empty_review = ( - request.data.get('review', {}).get('state') == 'commented' + request.data.get('review', {}).get('state', '').lower() == 'commented' and event == 'pull_request_review' and request.data.get('review', {}).get('body') is None ) is_black_non_main_push = ( request.data.get('ref') != 'refs/heads/main' - and repository.get('name') == 'black' - and repository.get('owner', {}).get('login') == 'psf' + and repository.get('name', '').lower() == 'black' + and repository.get('owner', {}).get('login', '').lower() == 'psf' and event == 'push' ) -- cgit v1.2.3 From ab17a7a80e6f90a4dddb04325c29deb8065f1499 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 12 Dec 2023 15:58:24 +0000 Subject: Simplify is_bot_pr_approval logic --- pydis_site/apps/api/views.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) (limited to 'pydis_site/apps') diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 20c7536c..1fa3efc2 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -272,10 +272,7 @@ class GitHubWebhookFilterView(APIView): 'dependabot' in request.data.get('ref', '').lower() and event == 'delete' ) - is_bot_pr_approval = ( - '[bot]' in request.data.get('pull_request', {}).get('user', {}).get('login', '').lower() - and event == 'pull_request_review' - ) + is_bot_pr_approval = is_github_bot and event == 'pull_request_review' is_empty_review = ( request.data.get('review', {}).get('state', '').lower() == 'commented' and event == 'pull_request_review' -- cgit v1.2.3