diff options
author | 2022-07-15 13:06:38 +0300 | |
---|---|---|
committer | 2022-07-15 13:33:21 +0300 | |
commit | a77f35b598b227d87db0955a0b2bc97839dcb978 (patch) | |
tree | 5c2759a37f8dad14d9970e432b698326a86db661 /pydis_site | |
parent | Add UniqueConstraint to the Filter model (diff) | |
parent | Merge pull request #740 from python-discord/update-django (diff) |
Merge branch 'main' into new-filter-schema
Diffstat (limited to 'pydis_site')
247 files changed, 5078 insertions, 2531 deletions
diff --git a/pydis_site/README.md b/pydis_site/README.md new file mode 100644 index 00000000..db402743 --- /dev/null +++ b/pydis_site/README.md @@ -0,0 +1,68 @@ +# `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**, which are the building blocks + that make up our Django project. A Django project must always consist of one + or more apps, and these apps can be made completely modular and reusable + across any Django project. In our project, each app controls a distinct part + of our website, such as the API or our resources system. + + For more information on reusable apps, see the official Django tutorial, + [which has a section on reusable + apps](https://docs.djangoproject.com/en/dev/intro/reusable-apps/). To learn + more about our specific apps, see the README inside the app folder itself. + +- [`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. For example, static files used by the `resources` app go in `static/resources`. + +- [`templates`](./templates) contains our **[Django + templates](https://docs.djangoproject.com/en/dev/topics/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. + + +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/``). + +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 controls all + manner of crucial things, for instance, we use it to configure logging, our + connection to the database, which applications are run by the project, which + middleware we are using, and variables for `django-simple-bulma` (which + determines frontend colours & extensions for our pages). + +- [`urls.py`](./urls.py), the URL configuration for the project itself. Here we + can forward certain URL paths to our different apps, which have their own + `urls.py` files to configure where their subpaths will lead. These files + determine _which URLs will lead to which Django views_. + +- [`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. + + +For more information about contributing to our projects, please see our +[Contributing +page](https://www.pythondiscord.com/pages/guides/pydis-guides/contributing/). + +[^1]: See [Django Glossary: project](https://docs.djangoproject.com/en/dev/glossary/#term-project) diff --git a/pydis_site/apps/admin/__init__.py b/pydis_site/apps/admin/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/pydis_site/apps/admin/__init__.py +++ /dev/null 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), -) diff --git a/pydis_site/apps/api/README.md b/pydis_site/apps/api/README.md new file mode 100644 index 00000000..1c6358b3 --- /dev/null +++ b/pydis_site/apps/api/README.md @@ -0,0 +1,71 @@ +# 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. (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 + 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. 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/0074_voice_mute.py b/pydis_site/apps/api/migrations/0074_voice_mute.py new file mode 100644 index 00000000..937557bc --- /dev/null +++ b/pydis_site/apps/api/migrations/0074_voice_mute.py @@ -0,0 +1,36 @@ +# Generated by Django 3.0.14 on 2021-10-09 18:52 +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def migrate_infractions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + Infraction = apps.get_model("api", "Infraction") + + for infraction in Infraction.objects.filter(type="voice_ban"): + infraction.type = "voice_mute" + infraction.save() + + +def unmigrate_infractions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + Infraction = apps.get_model("api", "Infraction") + + for infraction in Infraction.objects.filter(type="voice_mute"): + infraction.type = "voice_ban" + infraction.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0073_otn_allow_GT_and_LT'), + ] + + operations = [ + migrations.AlterField( + model_name='infraction', + name='type', + field=models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban'), ('voice_mute', 'Voice Mute')], help_text='The type of the infraction.', max_length=10), + ), + migrations.RunPython(migrate_infractions, unmigrate_infractions) + ] 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), ), ] diff --git a/pydis_site/apps/api/migrations/0079_merge_20220125_2022.py b/pydis_site/apps/api/migrations/0079_merge_20220125_2022.py new file mode 100644 index 00000000..9b9d9326 --- /dev/null +++ b/pydis_site/apps/api/migrations/0079_merge_20220125_2022.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.14 on 2022-01-25 20:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0078_merge_20211213_0552'), + ('api', '0074_voice_mute'), + ] + + operations = [ + ] 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..2c0c689a --- /dev/null +++ b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py @@ -0,0 +1,32 @@ +# 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='AocAccountLink', + fields=[ + ('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='AocCompletionistBlock', + fields=[ + ('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/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/migrations/0082_otn_allow_big_solidus.py b/pydis_site/apps/api/migrations/0082_otn_allow_big_solidus.py new file mode 100644 index 00000000..abbb98ec --- /dev/null +++ b/pydis_site/apps/api/migrations/0082_otn_allow_big_solidus.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.14 on 2022-04-21 23:29 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0081_bumpedthread'), + ] + + operations = [ + migrations.AlterField( + model_name='offtopicchannelname', + name='name', + field=models.CharField(help_text='The actual channel name that will be used on our Discord server.', max_length=96, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex="^[a-z0-9\\U0001d5a0-\\U0001d5b9-ǃ?’'<>⧹⧸]+$")]), + ), + ] 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/migrations/0079_new_filter_schema.py b/pydis_site/apps/api/migrations/0084_new_filter_schema.py index bd807f02..10e83b8b 100644 --- a/pydis_site/apps/api/migrations/0079_new_filter_schema.py +++ b/pydis_site/apps/api/migrations/0084_new_filter_schema.py @@ -82,7 +82,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: class Migration(migrations.Migration): dependencies = [ - ('api', '0078_merge_20211213_0552'), + ('api', '0083_remove_embed_validation'), ] operations = [ diff --git a/pydis_site/apps/api/migrations/0080_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0085_unique_constraint_filters.py index 0b3b4162..418c6e71 100644 --- a/pydis_site/apps/api/migrations/0080_unique_constraint_filters.py +++ b/pydis_site/apps/api/migrations/0085_unique_constraint_filters.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('api', '0079_new_filter_schema'), + ('api', '0084_new_filter_schema'), ] operations = [ diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index 63087990..580c95a0 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -3,14 +3,18 @@ from .bot import ( FilterList, Filter, BotSetting, + BumpedThread, DocumentationLink, DeletedMessage, + FilterList, Infraction, Message, MessageDeletionContext, 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 9ba763a4..6f09473d 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -1,10 +1,13 @@ # flake8: noqa from .filters import FilterList, Filter from .bot_setting import BotSetting +from .bumped_thread import BumpedThread from .deleted_message import DeletedMessage from .documentation_link import DocumentationLink from .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..acbc0eba --- /dev/null +++ b/pydis_site/apps/api/models/bot/aoc_completionist_block.py @@ -0,0 +1,26 @@ +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.OneToOneField( + User, + on_delete=models.CASCADE, + help_text="The user that is blocked from getting the AoC Completionist Role", + primary_key=True + ) + + 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( + null=True, + help_text="The reason for the AoC Completionist Role Block." + ) 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..4e9d4882 --- /dev/null +++ b/pydis_site/apps/api/models/bot/aoc_link.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 AocAccountLink(ModelReprMixin, models.Model): + """An AoC account link for a Discord User.""" + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + help_text="The user that is blocked from getting the AoC Completionist Role", + primary_key=True + ) + + 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/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/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py index 913631d4..c9303024 100644 --- a/pydis_site/apps/api/models/bot/infraction.py +++ b/pydis_site/apps/api/models/bot/infraction.py @@ -17,6 +17,7 @@ class Infraction(ModelReprMixin, models.Model): ("ban", "Ban"), ("superstar", "Superstar"), ("voice_ban", "Voice Ban"), + ("voice_mute", "Voice Mute"), ) inserted_at = models.DateTimeField( default=timezone.now, @@ -45,7 +46,7 @@ class Infraction(ModelReprMixin, models.Model): help_text="The user which applied the infraction." ) type = models.CharField( - max_length=9, + max_length=10, choices=TYPE_CHOICES, help_text="The type of the infraction." ) 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/bot/metricity.py b/pydis_site/apps/api/models/bot/metricity.py index 901f191a..abd25ef0 100644 --- a/pydis_site/apps/api/models/bot/metricity.py +++ b/pydis_site/apps/api/models/bot/metricity.py @@ -10,7 +10,7 @@ EXCLUDE_CHANNELS = ( ) -class NotFoundError(Exception): +class NotFoundError(Exception): # noqa: N818 """Raised when an entity cannot be found.""" pass diff --git a/pydis_site/apps/api/models/bot/off_topic_channel_name.py b/pydis_site/apps/api/models/bot/off_topic_channel_name.py index e9fec114..b380efad 100644 --- a/pydis_site/apps/api/models/bot/off_topic_channel_name.py +++ b/pydis_site/apps/api/models/bot/off_topic_channel_name.py @@ -11,7 +11,7 @@ class OffTopicChannelName(ModelReprMixin, models.Model): primary_key=True, max_length=96, validators=( - RegexValidator(regex=r"^[a-z0-9\U0001d5a0-\U0001d5b9-ǃ?’'<>]+$"), + RegexValidator(regex=r"^[a-z0-9\U0001d5a0-\U0001d5b9-ǃ?’'<>⧹⧸]+$"), ), help_text="The actual channel name that will be used on our Discord server." ) 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/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/api/serializers.py b/pydis_site/apps/api/serializers.py index 5a637976..0976ed29 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -12,8 +12,11 @@ from rest_framework.serializers import ( from rest_framework.settings import api_settings from rest_framework.validators import UniqueTogetherValidator -from .models import ( # noqa: I101 - Preserving the filter order +from .models import ( + AocAccountLink, + AocCompletionistBlock, BotSetting, + BumpedThread, DeletedMessage, DocumentationLink, Infraction, @@ -40,6 +43,32 @@ 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. + + 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. + """ + 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',) + + class DeletedMessageSerializer(ModelSerializer): """ A class providing (de-)serialization of `DeletedMessage` instances. @@ -340,13 +369,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.""" @@ -361,7 +383,7 @@ class InfractionSerializer(ModelSerializer): raise ValidationError({'expires_at': [f'{infr_type} infractions cannot expire.']}) hidden = attrs.get('hidden') - if hidden and infr_type in ('superstar', 'warning', 'voice_ban'): + if hidden and infr_type in ('superstar', 'warning', 'voice_ban', 'voice_mute'): raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']}) if not hidden and infr_type in ('note',): @@ -441,6 +463,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", "reason") + + +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/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) 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."}) diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index b3dd16ee..f1107734 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 @@ -79,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, @@ -87,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): @@ -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/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index c8f4e1b1..b9b14a84 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, @@ -11,7 +10,6 @@ from pydis_site.apps.api.models import ( FilterList, FilterSettings, Infraction, - Message, MessageDeletionContext, Nomination, NominationEntry, @@ -44,7 +42,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): @@ -102,7 +100,7 @@ class StringDunderMethodTests(SimpleTestCase): name='shawn', discriminator=555, ), - creation=dt.utcnow() + creation=dt.now(timezone.utc) ), embeds=[] ), @@ -131,17 +129,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, 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/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/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py index 9b91380b..5d10069d 100644 --- a/pydis_site/apps/api/tests/test_users.py +++ b/pydis_site/apps/api/tests/test_users.py @@ -1,10 +1,10 @@ -from unittest.mock import patch +import random +from unittest.mock import Mock, patch -from django.core.exceptions import ObjectDoesNotExist from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import Role, User +from ..models import Infraction, Role, User from ..models.bot.metricity import NotFoundError from ..viewsets.bot.user import UserListPagination @@ -424,7 +424,7 @@ class UserMetricityTests(AuthenticatedAPITestCase): self.assertCountEqual(response.json(), { "joined_at": joined_at, "total_messages": total_messages, - "voice_banned": False, + "voice_gate_blocked": False, "activity_blocks": total_blocks }) @@ -451,23 +451,36 @@ class UserMetricityTests(AuthenticatedAPITestCase): self.assertEqual(response.status_code, 404) def test_metricity_voice_banned(self): + queryset_with_values = Mock(spec=Infraction.objects) + queryset_with_values.filter.return_value = queryset_with_values + queryset_with_values.exists.return_value = True + + queryset_without_values = Mock(spec=Infraction.objects) + queryset_without_values.filter.return_value = queryset_without_values + queryset_without_values.exists.return_value = False cases = [ - {'exception': None, 'voice_banned': True}, - {'exception': ObjectDoesNotExist, 'voice_banned': False}, + {'voice_infractions': queryset_with_values, 'voice_gate_blocked': True}, + {'voice_infractions': queryset_without_values, 'voice_gate_blocked': False}, ] self.mock_metricity_user("foo", 1, 1, [["bar", 1]]) for case in cases: - with self.subTest(exception=case['exception'], voice_banned=case['voice_banned']): - with patch("pydis_site.apps.api.viewsets.bot.user.Infraction.objects.get") as p: - p.side_effect = case['exception'] + 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_banned"], case["voice_banned"]) + self.assertEqual( + response.json()["voice_gate_blocked"], + case["voice_gate_blocked"] + ) def test_metricity_review_data(self): # Given @@ -508,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/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)) diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index 4e8edaf0..d5f6bc56 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -2,8 +2,11 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter from .views import HealthcheckView, RulesView -from .viewsets import ( # noqa: I101 - Preserving the filter order +from .viewsets import ( + AocAccountLinkViewSet, + AocCompletionistBlockViewSet, BotSettingViewSet, + BumpedThreadViewSet, DeletedMessageViewSet, DocumentationLinkViewSet, FilterListViewSet, @@ -24,6 +27,14 @@ bot_router.register( FilterListViewSet ) bot_router.register( + "aoc-account-links", + AocAccountLinkViewSet +) +bot_router.register( + "aoc-completionist-blocks", + AocCompletionistBlockViewSet +) +bot_router.register( 'filter/filters', FilterViewSet ) @@ -32,6 +43,10 @@ bot_router.register( BotSettingViewSet ) bot_router.register( + 'bumped-threads', + BumpedThreadViewSet +) +bot_router.register( 'deleted-messages', DeletedMessageViewSet ) @@ -40,6 +55,10 @@ bot_router.register( DocumentationLinkViewSet ) bot_router.register( + 'filter-lists', + FilterListViewSet +) +bot_router.register( 'infractions', InfractionViewSet ) diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index 4cf4c655..1dae9be1 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -1,13 +1,17 @@ # flake8: noqa from .bot import ( BotSettingViewSet, + BumpedThreadViewSet, DeletedMessageViewSet, DocumentationLinkViewSet, + FilterListViewSet, InfractionViewSet, FilterListViewSet, FilterViewSet, 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 4649fcde..33b65009 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -4,12 +4,15 @@ from .filters import ( FilterViewSet ) from .bot_setting import BotSettingViewSet +from .bumped_thread import BumpedThreadViewSet from .deleted_message import DeletedMessageViewSet from .documentation_link import DocumentationLinkViewSet 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..3a4cec60 --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py @@ -0,0 +1,73 @@ +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, + ... "reason": "Too good to be true" + ... } + ... ] + + + ### GET /bot/aoc-completionist-blocks/<user__id:int> + Retrieve a single Block by User ID + + #### Response format + >>> + ... { + ... "user": 2, + ... "is_blocked": False, + ... "reason": "Too good to be true" + ... } + + #### 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, + ... "reason": string + ... } + + #### Status codes + - 204: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/aoc-completionist-blocks/<user__id:int> + Deletes the AoC Completionist block item with the given `user__id`. + + #### Status codes + - 204: returned on success + - 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", "is_blocked") 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..c7a96629 --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py @@ -0,0 +1,71 @@ +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/<user__id:int> + 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 was invalid + + ### DELETE /bot/aoc-account-links/<user__id:int> + Deletes the AoC account link item with the given `user__id`. + + #### Status codes + - 204: returned on success + - 404: returned if the AoC account link with the given `user__id` was not found + + """ + + serializer_class = AocAccountLinkSerializer + queryset = AocAccountLink.objects.all() + filter_backends = (DjangoFilterBackend,) + filter_fields = ("user__id", "aoc_username") 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..9d77bb6b --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/bumped_thread.py @@ -0,0 +1,66 @@ +from rest_framework.mixins import ( + 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 +from pydis_site.apps.api.serializers import BumpedThreadSerializer + + +class BumpedThreadViewSet( + GenericViewSet, CreateModelMixin, DestroyModelMixin, 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 + >>> list[int] + + #### Status codes + - 200: returned on success + - 401: returned if unauthenticated + + ### GET /bot/bumped-threads/<thread_id:int> + Returns whether a specific BumpedThread exists in the database. + + #### Status codes + - 204: 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/<thread_id:int> + 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() + + 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) diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index 8a48ed1f..7f31292f 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -1,7 +1,9 @@ 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 @@ -183,20 +185,24 @@ 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, + timezone=timezone.utc, + ) 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, + 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']: @@ -271,3 +277,28 @@ 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 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 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" diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py index 1a5e79f8..3318b2b9 100644 --- a/pydis_site/apps/api/viewsets/bot/user.py +++ b/pydis_site/apps/api/viewsets/bot/user.py @@ -1,7 +1,8 @@ import typing from collections import OrderedDict -from django.core.exceptions import ObjectDoesNotExist +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.""" @@ -261,12 +266,10 @@ class UserViewSet(ModelViewSet): """Request handler for metricity_data endpoint.""" user = self.get_object() - try: - Infraction.objects.get(user__id=user.id, active=True, type="voice_ban") - except ObjectDoesNotExist: - voice_banned = False - else: - voice_banned = True + has_voice_infraction = Infraction.objects.filter( + Q(user__id=user.id, active=True), + Q(type="voice_ban") | Q(type="voice_mute") + ).exists() with Metricity() as metricity: try: @@ -275,7 +278,7 @@ class UserViewSet(ModelViewSet): data["total_messages"] = metricity.total_messages(user.id) data["activity_blocks"] = metricity.total_message_blocks(user.id) - data["voice_banned"] = voice_banned + data["voice_gate_blocked"] = has_voice_infraction return Response(data, status=status.HTTP_200_OK) except NotFoundError: return Response(dict(detail="User not found in metricity"), diff --git a/pydis_site/apps/content/README.md b/pydis_site/apps/content/README.md new file mode 100644 index 00000000..e7061207 --- /dev/null +++ b/pydis_site/apps/content/README.md @@ -0,0 +1,32 @@ +# 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. + + +## Contributing pages + +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). + + +## 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/) + +- `tests` contains unit tests for verifying that the app works properly. + +- `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 +`utils.py` module contains utility functions for discovering Markdown files to +serve. 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/content/migrations/__init__.py b/pydis_site/apps/content/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/pydis_site/apps/content/migrations/__init__.py +++ /dev/null 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 d367dbc7..6231fe87 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: <!-- Project cards --> <div class="columns is-multiline is-centered is-3 is-variable"> @@ -19,15 +19,11 @@ Our projects on Python Discord are open source and [available on Github](https:/ </div> <div class="card-content"> <div class="content"> - Our community-driven Discord bot. - </div> - <div class="tags has-addons"> - <span class="tag is-dark">Difficulty</span> - <span class="tag is-primary">Beginner</span> + 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! </div> </div> <div class="card-footer"> - <a href="https://github.com/python-discord/sir-lancebot/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc" class="card-footer-item"><i class="far fa-exclamation-circle"></i> Issues</a> + <a href="https://github.com/python-discord/sir-lancebot/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc" class="card-footer-item"><i class="fas fa-exclamation-circle"></i> Issues</a> <a href="https://github.com/python-discord/sir-lancebot/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc" class="card-footer-item"><i class="fas fa-code-merge"></i> PRs</a> </div> <div class="card-footer"> @@ -46,15 +42,11 @@ Our projects on Python Discord are open source and [available on Github](https:/ </div> <div class="card-content"> <div class="content"> - The community and moderation Discord bot. - </div> - <div class="tags has-addons"> - <span class="tag is-dark">Difficulty</span> - <span class="tag is-warning">Intermediate</span> + Called @Python on the server, this bot handles moderation tools, help channels, and other critical features for our community. </div> </div> <div class="card-footer"> - <a href="https://github.com/python-discord/bot/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc" class="card-footer-item"><i class="far fa-exclamation-circle"></i> Issues</a> + <a href="https://github.com/python-discord/bot/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc" class="card-footer-item"><i class="fas fa-exclamation-circle"></i> Issues</a> <a href="https://github.com/python-discord/bot/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc" class="card-footer-item"><i class="fas fa-code-merge"></i> PRs</a> </div> <div class="card-footer"> @@ -73,15 +65,11 @@ Our projects on Python Discord are open source and [available on Github](https:/ </div> <div class="card-content"> <div class="content"> - The website, subdomains and API. - </div> - <div class="tags has-addons"> - <span class="tag is-dark">Difficulty</span> - <span class="tag is-danger">Advanced</span> + This website itself! This project is built with Django and includes our API, which is used by various services such as @Python. </div> </div> <div class="card-footer"> - <a href="https://github.com/python-discord/site/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc" class="card-footer-item"><i class="far fa-exclamation-circle"></i> Issues</a> + <a href="https://github.com/python-discord/site/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc" class="card-footer-item"><i class="fas fa-exclamation-circle"></i> Issues</a> <a href="https://github.com/python-discord/site/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc" class="card-footer-item"><i class="fas fa-code-merge"></i> PRs</a> </div> <div class="card-footer"> @@ -91,26 +79,64 @@ Our projects on Python Discord are open source and [available on Github](https:/ </div> </div> -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! +# 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). + +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 } + +### 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. + +Check out our [**guide on cloning a GitHub repo**](./cloning-repository/). + +### 2. 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 main projects: + +* [**Sir Lancebot**](./sir-lancebot/) + +* [**Python Bot**](./bot/) + +* [**Site**](./site/) + +### 3. 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/). + +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. + +[**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 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/) -### Useful Resources +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 } -[Guidelines](./contributing-guidelines/) - General guidelines you should follow when contributing to our projects.<br> -[Style Guide](./style-guide/) - Information regarding the code styles you should follow when working on our projects.<br> -[Review Guide](../code-reviews-primer/) - A guide to get you started on doing code reviews. +### 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. -## 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. +This is a good time to review [how to write good commit messages](./contributing-guidelines/commit-messages) if you haven't already. -#### Guidelines for the @Contributors Role +### 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. -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: +Check out our [**Pull Request Guide**](./pull-requests/) for help with opening a pull request and going through the review process. -- The member has made several significant contributions to our projects. -- The member has a positive influence in our contributors subcommunity. +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! -The role will be assigned at the discretion of the Admin Team in consultation with the Core Developers Team. +### That's it! +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`](https://discord.gg/2h3qBv8Xaa) 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! 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 b9589def..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,34 +5,9 @@ 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). -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 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). -<div class="card"> - <button type="button" class="card-header collapsible"> - <span class="card-header-title subtitle is-6 my-2 ml-2">Getting started with Git and GitHub</span> - <span class="card-header-icon"> - <i class="fas fa-angle-down title is-5" aria-hidden="true"></i> - </span> - </button> - <div class="collapsible-content"> - <div class="card-content"> - <p>If you don't have Git on your computer already, <a href="https://git-scm.com/downloads">install it</a>. You can additionally install a Git GUI such as <a href="https://www.gitkraken.com/download">GitKraken</a>, or the <a href="https://cli.github.com/manual/installation">GitHub CLI</a>.</p> - <p>To learn more about Git, you can look into <a href="../working-with-git">our guides</a>, as well as <a href="https://education.github.com/git-cheat-sheet-education.pdf">this cheatsheet</a>, <a href="https://learngitbranching.js.org">Learn Git Branching</a>, and otherwise any guide you can find on the internet. Once you got the basic idea though, the best way to learn Git is to use it.</p> - <p>Creating a copy of a repository under your own account is called a <em>fork</em>. This is where all your changes and commits will be pushed to, and from where your pull requests will originate from.</p> - <p><strong><a href="../forking-repository">Learn about forking a project</a></strong>.</p> - </div> - </div> -</div> -<br> - -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. +This page will focus on the quickest steps one can take, with mentions of alternatives afterwards. --- @@ -78,10 +53,10 @@ See [here](../obtaining-discord-ids) for help with obtaining Discord IDs. <button type="button" class="card-header collapsible"> <span class="card-header-title subtitle is-6 my-2 ml-2">Optional config.yml</span> <span class="card-header-icon"> - <i class="fas fa-angle-down title is-5" aria-hidden="true"></i> + <i class="fas fa-fw fa-angle-down title is-5" aria-hidden="true"></i> </span> </button> - <div class="collapsible-content"> + <div class="collapsible-content collapsed"> <div class="card-content"> <p>If you used the provided server template, and you're not sure which channels belong where in the config file, you can use the config below. Pay attention to the comments with several <code>#</code> symbols, and replace the <code>�</code> characters with the right IDs.</p> <pre> @@ -196,6 +171,7 @@ guild: big_brother: � dev_log: � duck_pond: � + incidents: � incidents_archive: � python_news: &PYNEWS_WEBHOOK � talent_pool: � @@ -350,7 +326,7 @@ style: trashcan: "<:trashcan:�>" -##### << Optional - If you don't care about the filtering and help channel cogs, ignore the rest of this file >> ##### +##### << Optional - If you don't care about the filtering, help channel and py-news cogs, ignore the rest of this file >> ##### filter: # What do we filter? filter_domains: true @@ -426,6 +402,10 @@ help_channels: notify_roles: - *HELPERS_ROLE +python_news: + channel: *DEV_PY_NEWS + webhook: *PYNEWS_WEBHOOK + ##### << Add any additional sections you need to override from config-default.yml >> ##### </code> </pre> @@ -453,10 +433,10 @@ We understand this is tedious and are working on a better solution for setting u <button type="button" class="card-header collapsible"> <span class="card-header-title subtitle is-6 my-2 ml-2">Why do you need a separate config file?</span> <span class="card-header-icon"> - <i class="fas fa-angle-down title is-5" aria-hidden="true"></i> + <i class="fas fa-fw fa-angle-down title is-5" aria-hidden="true"></i> </span> </button> - <div class="collapsible-content"> + <div class="collapsible-content collapsed"> <div class="card-content"> While it's technically possible to edit <code>config-default.yml</code> to match your server, it is heavily discouraged. This file's purpose is to provide the configurations the Python bot needs to run in the Python server in production, and should remain as such. @@ -482,10 +462,10 @@ You are now almost ready to run the Python bot. The simplest way to do so is wit <button type="button" class="card-header collapsible"> <span class="card-header-title subtitle is-6 my-2 ml-2">Getting started with Docker</span> <span class="card-header-icon"> - <i class="fas fa-angle-down title is-5" aria-hidden="true"></i> + <i class="fas fa-fw fa-angle-down title is-5" aria-hidden="true"></i> </span> </button> - <div class="collapsible-content"> + <div class="collapsible-content collapsed"> <div class="card-content"> The requirements for Docker are: <ul> @@ -536,10 +516,10 @@ With at least the site running in Docker already (see the previous section on ho <button type="button" class="card-header collapsible"> <span class="card-header-title subtitle is-6 my-2 ml-2">Ways to run code</span> <span class="card-header-icon"> - <i class="fas fa-angle-down title is-5" aria-hidden="true"></i> + <i class="fas fa-fw fa-angle-down title is-5" aria-hidden="true"></i> </span> </button> - <div class="collapsible-content"> + <div class="collapsible-content collapsed"> <div class="card-content"> Notice that the bot is started as a module. There are several ways to do so: <ul> @@ -565,10 +545,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/) @@ -659,4 +636,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/contributing-guidelines.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md index de1777f2..73c5dcab 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! +<br/> + +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`](https://discord.gg/2h3qBv8Xaa) 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..f6f8a5f2 --- /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` in the console. 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 new file mode 100644 index 00000000..d193a455 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/pull-requests.md @@ -0,0 +1,40 @@ +--- +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](../linting/) 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`: + + + +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/apps/content/resources/guides/pydis-guides/contributing/setting-test-server-and-bot-account.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/setting-test-server-and-bot-account.md index c14fe50d..43d1c8f5 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/setting-test-server-and-bot-account.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/setting-test-server-and-bot-account.md @@ -18,13 +18,11 @@ 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, or 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**. +6. In the `OAuth2` tab, grab the **Client ID**. 7. Replace `<CLIENT_ID_HERE>` in the following URL and visit it in the browser to invite your bot to your new test server. ```plaintext https://discordapp.com/api/oauth2/authorize?client_id=<CLIENT_ID_HERE>&permissions=8&scope=bot ``` -Optionally, you can generate your own invite url in the `OAuth` tab, after selecting `bot` as the scope. - --- ## Obtain the IDs 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..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,10 +17,12 @@ 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. -To do this, you will need a Gitpod account, which you can get [here](https://www.gitpod.io/#get-started), and a fork of Sir Lancebot. This guide covers forking the repository [here](#fork-the-project). +To do this, you will need a Gitpod account, which you can get [here](https://www.gitpod.io/#get-started), and a fork of Sir Lancebot. This guide covers forking the repository [here](../forking-repository). Afterwards, click on [this link](https://gitpod.io/#/github.com/python-discord/sir-lancebot) to spin up a new workspace for Sir Lancebot. Then run the following commands in the terminal after the existing tasks have finished running: ```sh @@ -41,19 +44,8 @@ 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) +If you aren't using Docker, you will need to [install the project's dependencies](../installing-project-dependencies) yourself. --- # Test Server and Bot Account @@ -120,14 +112,11 @@ After installing project dependencies use the poetry command `poetry run task st ```shell $ 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. +# 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. -[**Click here to see the basic Git workflow when contributing to one of our projects.**](../working-with-git/) +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 f2c3bd95..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) @@ -27,22 +29,9 @@ Without Docker: - 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 -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 @@ -178,3 +167,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! 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."<br> **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. 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) 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..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 @@ -6,70 +6,86 @@ 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**. -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. +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.  -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. +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 +## 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.  +*The Available Help Channels category is always at the top of the server's channel list.* + + +*This message indicates that a channel is available.* + +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! -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 messages indicates that you've claimed the channel.* -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. +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. - -*This message is always posted when a channel becomes available for use.* +# Frequently Asked Questions -## Q: For how long is the channel mine? +### How long does my help channel stay active? -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 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 when the channel is marked as inactive.* +*You'll see this message in your channel once it goes dormant.* +### 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, 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 increase your chances of getting a response. + +### 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. + +### 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 don't need my help channel anymore, my question was answered. What do I do? +Tip: run the `!helpdm on` command in `#bot-commands` to get notified via DM with jumplinks to help channels you're participating in. -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. +### What are the available, occupied, and dormant categories? -## Q: Are only Helpers supposed to answer questions? +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**. -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. +### 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. -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). +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 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:<your nickname>` 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/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. 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 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..62ff61f9 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md @@ -0,0 +1,68 @@ +--- +title: Discord Messages with Colors +description: A guide on how to add colors to your codeblocks on Discord +--- + +Discord is now slowly rolling out the ability to send colored text within code blocks. This is done using ANSI color codes which is also how you print colored text in your terminal. + +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 +``` +*`\u001b` is the unicode escape for ESCAPE/ESC, meant to be used in the source of your bot (see <http://www.unicode-symbol.com/u/001B.html>).* ***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 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}`: + +* 0: Normal +* 1: **Bold** +* 4: <ins>Underline</ins> + +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: Firefly dark blue +* 41: Orange +* 42: Marble blue +* 43: Greyish turquoise +* 44: Gray +* 45: Indigo +* 46: Light 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: +````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? +``` +```` + +Result: + + + +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](https://gist.github.com/matthewzring/9f7bbfd102003963f9be7dbcf7d40e51)**. 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..4b475146 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/discordpy_help_command.md @@ -0,0 +1,66 @@ +--- +title: Custom Help Command +description: "Overwrite discord.py's help command to implement custom functionality" +--- + +First, a basic walkthrough can be found [here](https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96) by Stella#2000 on subclassing the HelpCommand. It will provide some foundational knowledge that is required before attempting a more customizable help command. + +## Custom Subclass of Help Command +If the types of classes of the HelpCommand do not fit your needs, you can subclass HelpCommand and use the class mehods to customize the output. Below is a simple demonstration using the following methods that can also be found on the documenation: + +- [filter_commands](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.HelpCommand.filter_commands) + +- [send_group_help](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.HelpCommand.send_bot_help) + +- [send_command_help](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.HelpCommand.send_command_help) + +- [send_group_help](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.HelpCommand.send_group_help) + +- [send_error_message](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.HelpCommand.send_error_message) + +```python +class MyHelp(commands.HelpCommand): + + async def send_bot_help(self, mapping): + """ + This is triggered when !help is invoked. + + This example demonstrates how to list the commands that the member invoking the help command can run. + """ + filtered = await self.filter_commands(self.context.bot.commands, sort=True) # returns a list of command objects + names = [command.name for command in filtered] # iterating through the commands objects getting names + available_commands = "\n".join(names) # joining the list of names by a new line + embed = disnake.Embed(description=available_commands) + await self.context.send(embed=embed) + + async def send_command_help(self, command): + """This is triggered when !help <command> is invoked.""" + await self.context.send("This is the help page for a command") + + async def send_group_help(self, group): + """This is triggered when !help <group> is invoked.""" + await self.context.send("This is the help page for a group command") + + async def send_cog_help(self, cog): + """This is triggered when !help <cog> is invoked.""" + await self.context.send("This is the help page for a cog") + + async def send_error_message(self, error): + """If there is an error, send a embed containing the error.""" + channel = self.get_destination() # this defaults to the command context channel + await channel.send(error) + +bot.help_command = MyHelp() +``` + +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) 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..0acd3e55 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/vps-services.md @@ -0,0 +1,31 @@ +--- +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. + +* 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/) + +--- +# 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. 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. 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. 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 diff --git a/pydis_site/apps/content/resources/server-info/roles.md b/pydis_site/apps/content/resources/server-info/roles.md index 716f5b1e..409e037e 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. --- @@ -68,7 +72,7 @@ In addition to the informal descriptions below, we've also written down a more f ### <span class="fas fa-circle" style="color:#1abc9c"></span> 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. -### <span class="fas fa-circle" style="color:#8dc2ba"></span> Project Leads +### <span class="fas fa-circle" style="color:#00aeb4"></span> 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. ### <span class="fas fa-circle" style="color:#ff9f1b"></span> Moderators @@ -80,8 +84,8 @@ In addition to the informal descriptions below, we've also written down a more f ### <span class="fas fa-circle" style="color:#a1d1ff"></span> DevOps **Description:** A role for staff involved with the DevOps toolchain of our core projects. -### <span class="fas fa-circle" style="color:#f8d188"></span> Project Teams -**Description:** Staff can join teams which work on specific projects in the organisation, such as our code jams, media projects, and more. +### <span class="fas fa-circle" style="color:#7de29c"></span> 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. ### <span class="fas fa-circle" style="color:#eecd36"></span> Helpers **Description:** This is the core staff role in our organization: All staff members have the Helpers role. diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py index fe7c2852..f8496095 100644 --- a/pydis_site/apps/content/urls.py +++ b/pydis_site/apps/content/urls.py @@ -30,7 +30,7 @@ def __get_all_files(root: Path, folder: typing.Optional[Path] = None) -> list[st def get_all_pages() -> typing.Iterator[dict[str, str]]: - """Yield a dict of all pag categories.""" + """Yield a dict of all page categories.""" for location in __get_all_files(Path("pydis_site", "apps", "content", "resources")): yield {"location": location} 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/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/home/tests/test_repodata_helpers.py b/pydis_site/apps/home/tests/test_repodata_helpers.py index 5634bc9b..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,12 +117,15 @@ 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'] - 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): diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py index e28a3a00..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 @@ -10,7 +10,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 +42,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 = {} @@ -57,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=TIMEOUT_PERIOD + 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/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/redirect/redirects.yaml b/pydis_site/apps/redirect/redirects.yaml index 9bcf3afd..4a48ba0c 100644 --- a/pydis_site/apps/redirect/redirects.yaml +++ b/pydis_site/apps/redirect/redirects.yaml @@ -83,13 +83,54 @@ 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" -resources_resources_redirect: - original_path: pages/resources/<str:category>/ - redirect_route: "resources:resources" +resources_reading_redirect: + original_path: resources/reading/ + redirect_route: "resources:index" + redirect_arguments: ["book"] + +resources_books_redirect: + original_path: resources/books/ + redirect_route: "resources:index" + redirect_arguments: ["book"] + +resources_videos_redirect: + original_path: resources/videos/ + redirect_route: "resources:index" + redirect_arguments: ["video"] + +resources_courses_redirect: + original_path: resources/courses/ + redirect_route: "resources:index" + redirect_arguments: ["course"] + +resources_communities_redirect: + original_path: resources/communities/ + redirect_route: "resources:index" + redirect_arguments: ["community"] + +resources_podcasts_redirect: + original_path: resources/podcasts/ + redirect_route: "resources:index" + redirect_arguments: ["podcast"] + +resources_tutorials_redirect: + original_path: resources/tutorials/ + redirect_route: "resources:index" + redirect_arguments: ["tutorial"] + +resources_tools_redirect: + original_path: resources/tools/ + redirect_route: "resources:index" + redirect_arguments: ["tool"] # Events events_index_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/resources/resources/communities/adafruit.yaml b/pydis_site/apps/resources/resources/adafruit.yaml index e5c81a6c..c687f507 100644 --- a/pydis_site/apps/resources/resources/communities/adafruit.yaml +++ b/pydis_site/apps/resources/resources/adafruit.yaml @@ -1,15 +1,22 @@ +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, provide help with your projects, and the Adafruit devs do all the CircuitPython Development right out in the open. title_image: https://www.mouser.com/images/suppliers/logos/adafruit.png -title_url: https://discord.gg/adafruit -position: 4 +title_url: https://adafruit.com/ urls: -- icon: branding/discord - url: https://discord.gg/adafruit - color: blurple -- icon: regular/link - url: https://adafruit.com/ - color: teal + - icon: branding/discord + url: https://discord.gg/adafruit + color: blurple +tags: + topics: + - microcontrollers + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - community diff --git a/pydis_site/apps/resources/resources/reading/books/automate_the_boring_stuff.yaml b/pydis_site/apps/resources/resources/automate_the_boring_stuff_book.yaml index 3812029c..63f63193 100644 --- a/pydis_site/apps/resources/resources/reading/books/automate_the_boring_stuff.yaml +++ b/pydis_site/apps/resources/resources/automate_the_boring_stuff_book.yaml @@ -4,11 +4,18 @@ description: One of the best books out there for Python beginners. This book wil the web, manipulating files and automating keyboard and mouse input. Ideal for an office worker who wants to make himself more useful. name: Automate the Boring Stuff with Python -position: 2 +title_url: https://automatetheboringstuff.com/ urls: -- icon: regular/book - url: https://automatetheboringstuff.com/ +- icon: branding/goodreads + url: https://www.goodreads.com/book/show/22514127-automate-the-boring-stuff-with-python color: black -- icon: branding/amazon - url: https://www.amazon.com/Automate-Boring-Stuff-Python-Programming/dp/1593275994/ - color: amazon-orange +tags: + topics: + - general + payment_tiers: + - free + - paid + difficulty: + - beginner + type: + - book 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 new file mode 100644 index 00000000..4632f5bd --- /dev/null +++ b/pydis_site/apps/resources/resources/automate_the_boring_stuff_course.yaml @@ -0,0 +1,13 @@ +description: The interactive course version of Al Sweigart's excellent book for beginners, taught by the author himself. +name: Automate the Boring Stuff with Python Udemy Course +title_url: https://www.udemy.com/automate/ +tags: + topics: + - general + payment_tiers: + - paid + difficulty: + - beginner + type: + - course + - interactive diff --git a/pydis_site/apps/resources/resources/communities/awesome_programming_discord.yaml b/pydis_site/apps/resources/resources/awesome_programming_discord.yaml index 335ac507..0ef7aefc 100644 --- a/pydis_site/apps/resources/resources/communities/awesome_programming_discord.yaml +++ b/pydis_site/apps/resources/resources/awesome_programming_discord.yaml @@ -6,4 +6,13 @@ title_icon: branding/github title_icon_color: black title_url: https://github.com/mhxion/awesome-programming-discord name: awesome-programming-discord -position: 10 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - community diff --git a/pydis_site/apps/resources/resources/reading/books/byte_of_python.yaml b/pydis_site/apps/resources/resources/byte_of_python.yaml index 2530c1a4..c2f6ab84 100644 --- a/pydis_site/apps/resources/resources/reading/books/byte_of_python.yaml +++ b/pydis_site/apps/resources/resources/byte_of_python.yaml @@ -2,14 +2,21 @@ description: A free book on programming using the Python language. It serves as a tutorial or guide to the Python language for a beginner audience. If all you know about computers is how to save text files, then this is the book for you. name: A Byte of Python -position: 1 +title_url: https://python.swaroopch.com/ urls: -- icon: regular/link - url: https://python.swaroopch.com/ - color: teal - icon: regular/book url: https://www.lulu.com/shop/swaroop-c-h/a-byte-of-python/paperback/product-21142968.html color: black -- icon: branding/amazon - url: https://www.amazon.com/Byte-Python-Swaroop-C-H-ebook/dp/B00FJ7S2JU/ - color: amazon-orange +- icon: branding/goodreads + url: https://www.goodreads.com/book/show/6762544-a-byte-of-python + color: black +tags: + topics: + - general + payment_tiers: + - free + - paid + difficulty: + - beginner + type: + - book diff --git a/pydis_site/apps/resources/resources/interactive/code_combat.yaml b/pydis_site/apps/resources/resources/code_combat.yaml index 30f20c28..84597c4d 100644 --- a/pydis_site/apps/resources/resources/interactive/code_combat.yaml +++ b/pydis_site/apps/resources/resources/code_combat.yaml @@ -1,11 +1,20 @@ description: Learn Python while gaming - an open-source project with thousands of contributors, which teaches you Python through a deep, top-down RPG. name: Code Combat -position: 0 +title_url: https://codecombat.com/ urls: -- icon: regular/link - url: https://codecombat.com/ - color: teal - icon: branding/github url: https://github.com/codecombat/codecombat color: black +tags: + topics: + - general + - algorithms and data structures + payment_tiers: + - free + - subscription + difficulty: + - beginner + - intermediate + type: + - interactive diff --git a/pydis_site/apps/resources/resources/communities/_category_info.yaml b/pydis_site/apps/resources/resources/communities/_category_info.yaml deleted file mode 100644 index b9cb6533..00000000 --- a/pydis_site/apps/resources/resources/communities/_category_info.yaml +++ /dev/null @@ -1,2 +0,0 @@ -description: Partnered communities that share part of our mission. -name: Communities diff --git a/pydis_site/apps/resources/resources/videos/corey_schafer.yaml b/pydis_site/apps/resources/resources/corey_schafer.yaml index a7cca18a..d66ea004 100644 --- a/pydis_site/apps/resources/resources/videos/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: <ul> @@ -9,11 +10,21 @@ description: 'Corey has a number of exceptionally high quality tutorial series Check out his channel for more video series! ' title_image: https://i.imgur.com/KIfWw3b.png -position: 0 +title_url: https://www.youtube.com/channel/UCCezIgC97PvUuR4_gbFUs5g urls: - - icon: branding/youtube - url: https://www.youtube.com/channel/UCCezIgC97PvUuR4_gbFUs5g - color: youtube-red - - icon: regular/link + - icon: solid/external-link-alt url: https://coreyms.com/ color: teal +tags: + topics: + - general + - software design + - web development + - tooling + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - video diff --git a/pydis_site/apps/resources/resources/courses/_category_info.yaml b/pydis_site/apps/resources/resources/courses/_category_info.yaml deleted file mode 100644 index 948b48de..00000000 --- a/pydis_site/apps/resources/resources/courses/_category_info.yaml +++ /dev/null @@ -1,4 +0,0 @@ -description: Listing of best Python courses. -name: Courses -default_icon: regular/graduation-cap -default_icon_color: black diff --git a/pydis_site/apps/resources/resources/courses/automate_the_boring_stuff_with_python.yaml b/pydis_site/apps/resources/resources/courses/automate_the_boring_stuff_with_python.yaml deleted file mode 100644 index 66034ea2..00000000 --- a/pydis_site/apps/resources/resources/courses/automate_the_boring_stuff_with_python.yaml +++ /dev/null @@ -1,5 +0,0 @@ -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 -position: 3 diff --git a/pydis_site/apps/resources/resources/data_science_from_scratch.yaml b/pydis_site/apps/resources/resources/data_science_from_scratch.yaml new file mode 100644 index 00000000..86955fdb --- /dev/null +++ b/pydis_site/apps/resources/resources/data_science_from_scratch.yaml @@ -0,0 +1,22 @@ +description: Data Science from Scratch is a good introduction to data science for the complete beginner, and covers + some of the fundamentals of Python programming as well as the basic math, probability and statistics needed to get + started. While either edition of this book is useful for those with prior Python experience, complete beginners + should use the second edition, which contains more up-to-date code examples and better practices. +name: Data Science from Scratch +title_url: https://www.oreilly.com/library/view/data-science-from/9781492041122/ +urls: + - icon: branding/goodreads + url: https://www.goodreads.com/en/book/show/52059715-data-science-from-scratch + color: black + - icon: branding/github + url: https://github.com/joelgrus/data-science-from-scratch + color: black +tags: + topics: + - data science + payment_tiers: + - paid + difficulty: + - beginner + type: + - book diff --git a/pydis_site/apps/resources/resources/interactive/edublocks.yaml b/pydis_site/apps/resources/resources/edublocks.yaml index 7c6ca02b..3eaefc35 100644 --- a/pydis_site/apps/resources/resources/interactive/edublocks.yaml +++ b/pydis_site/apps/resources/resources/edublocks.yaml @@ -7,4 +7,12 @@ description: EduBlocks provides a simple drag and drop interface to help beginne and export the code to run on actual devices. name: EduBlocks title_url: https://edublocks.org/ -position: 5 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + type: + - interactive diff --git a/pydis_site/apps/resources/resources/effective_python.yaml b/pydis_site/apps/resources/resources/effective_python.yaml new file mode 100644 index 00000000..b82fa0c3 --- /dev/null +++ b/pydis_site/apps/resources/resources/effective_python.yaml @@ -0,0 +1,21 @@ +description: A book that gives 90 best practices for writing excellent Python. Great + for intermediates. +name: Effective Python +title_url: https://effectivepython.com/ +urls: +- icon: branding/goodreads + url: https://www.goodreads.com/book/show/48566725-effective-python + color: black +- icon: branding/github + url: https://github.com/bslatkin/effectivepython + color: black +tags: + topics: + - general + - software design + payment_tiers: + - paid + difficulty: + - intermediate + type: + - book diff --git a/pydis_site/apps/resources/resources/interactive/exercism.yaml b/pydis_site/apps/resources/resources/exercism.yaml index 68b458d0..c623db2d 100644 --- a/pydis_site/apps/resources/resources/interactive/exercism.yaml +++ b/pydis_site/apps/resources/resources/exercism.yaml @@ -2,12 +2,19 @@ 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 -position: 1 +name: Exercism +title_url: https://exercism.org/ urls: -- icon: regular/link - url: https://exercism.io/ - color: teal - icon: branding/github url: https://github.com/exercism/python color: black +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - interactive diff --git a/pydis_site/apps/resources/resources/flask_web_development.yaml b/pydis_site/apps/resources/resources/flask_web_development.yaml new file mode 100644 index 00000000..6905b2b4 --- /dev/null +++ b/pydis_site/apps/resources/resources/flask_web_development.yaml @@ -0,0 +1,21 @@ +description: A comprehensive Flask walkthrough that has you building a complete social + blogging application from scratch. +name: Flask Web Development +title_url: http://shop.oreilly.com/product/0636920031116.do +urls: +- icon: branding/goodreads + url: https://www.goodreads.com/book/show/18774655-flask-web-development + color: black +- icon: branding/github + url: https://github.com/miguelgrinberg/flasky + color: black +tags: + topics: + - web development + payment_tiers: + - paid + difficulty: + - beginner + - intermediate + type: + - book diff --git a/pydis_site/apps/resources/resources/fluent_python.yaml b/pydis_site/apps/resources/resources/fluent_python.yaml new file mode 100644 index 00000000..c22fd388 --- /dev/null +++ b/pydis_site/apps/resources/resources/fluent_python.yaml @@ -0,0 +1,21 @@ +description: A veritable tome of intermediate and advanced Python information. A must-read + for any Python professional. By far the most recommended book for intermediates. +name: Fluent Python +title_url: https://www.oreilly.com/library/view/fluent-python/9781491946237/ +urls: +- icon: branding/goodreads + url: https://www.goodreads.com/book/show/22800567-fluent-python + color: black +- icon: branding/github + url: https://github.com/fluentpython + color: black +tags: + topics: + - general + - software design + payment_tiers: + - paid + difficulty: + - intermediate + type: + - book diff --git a/pydis_site/apps/resources/resources/reading/tutorials/getting_started_with_kivy.yaml b/pydis_site/apps/resources/resources/getting_started_with_kivy.yaml index d1d9a7d2..06eb2c14 100644 --- a/pydis_site/apps/resources/resources/reading/tutorials/getting_started_with_kivy.yaml +++ b/pydis_site/apps/resources/resources/getting_started_with_kivy.yaml @@ -2,4 +2,13 @@ description: A big list of excellent resources for getting started making Kivy a 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 -position: 3 +tags: + topics: + - user interface + - game development + payment_tiers: + - free + difficulty: + - beginner + type: + - tutorial diff --git a/pydis_site/apps/resources/resources/reading/tutorials/getting_started_with_python_for_non_programmers.yaml b/pydis_site/apps/resources/resources/getting_started_with_python_for_non_programmers.yaml index 3250a7c4..6fab0114 100644 --- a/pydis_site/apps/resources/resources/reading/tutorials/getting_started_with_python_for_non_programmers.yaml +++ b/pydis_site/apps/resources/resources/getting_started_with_python_for_non_programmers.yaml @@ -2,4 +2,12 @@ description: A list of beginner resources for programmers with no prior develope from Python's official guide. name: Getting Started with Python for Non-Programmers title_url: https://wiki.python.org/moin/BeginnersGuide/NonProgrammers -position: 1 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + type: + - tutorial diff --git a/pydis_site/apps/resources/resources/reading/tutorials/getting_started_with_python_for_programmers.yaml b/pydis_site/apps/resources/resources/getting_started_with_python_for_programmers.yaml index b65e0e12..74b6efb9 100644 --- a/pydis_site/apps/resources/resources/reading/tutorials/getting_started_with_python_for_programmers.yaml +++ b/pydis_site/apps/resources/resources/getting_started_with_python_for_programmers.yaml @@ -3,3 +3,12 @@ description: A list of beginner resources for programmers coming from other lang name: Getting Started with Python for Programmers title_url: https://wiki.python.org/moin/BeginnersGuide/Programmers position: 0 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - intermediate + type: + - tutorial diff --git a/pydis_site/apps/resources/resources/google_colab.yaml b/pydis_site/apps/resources/resources/google_colab.yaml new file mode 100644 index 00000000..5e1ca677 --- /dev/null +++ b/pydis_site/apps/resources/resources/google_colab.yaml @@ -0,0 +1,17 @@ +description: Google Colab is a custom version of Jupyter Notebook that runs code in the cloud, allowing you to + share your Colab notebooks with other people and work collaboratively. + Colab offers a generous amount of memory and computation time for free, and allows you to run programs on GPUs, + making it a great deep learning sandbox for beginners. +name: Google Colab +title_url: https://colab.research.google.com/notebooks/intro.ipynb +tags: + topics: + - general + - data science + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tool diff --git a/pydis_site/apps/resources/resources/hitchhikers_guide_to_python.yaml b/pydis_site/apps/resources/resources/hitchhikers_guide_to_python.yaml new file mode 100644 index 00000000..e48e5717 --- /dev/null +++ b/pydis_site/apps/resources/resources/hitchhikers_guide_to_python.yaml @@ -0,0 +1,18 @@ +description: A best practice handbook for both novice and expert Python developers to the installation, + configuration, and usage of Python on a daily basis. +name: The Hitchhiker's Guide to Python +title_url: https://python-guide.org/ +urls: +- icon: branding/goodreads + url: https://www.goodreads.com/book/show/28321007-the-hitchhiker-s-guide-to-python + color: black +tags: + topics: + - general + payment_tiers: + - paid + difficulty: + - beginner + - intermediate + type: + - book diff --git a/pydis_site/apps/resources/resources/reading/books/inferential_thinking.yaml b/pydis_site/apps/resources/resources/inferential_thinking.yaml index 27fad4f7..a8cf2bc8 100644 --- a/pydis_site/apps/resources/resources/reading/books/inferential_thinking.yaml +++ b/pydis_site/apps/resources/resources/inferential_thinking.yaml @@ -2,8 +2,14 @@ description: Inferential Thinking is the textbook for the <a href="http://data8. It introduces you the fundamentals of both Data Science and Python at a level accessible to all. It is available both through your browser and in PDF form. name: Inferential Thinking -position: 13 -urls: - - icon: regular/link - url: https://www.inferentialthinking.com/chapters/intro - color: teal +title_url: https://inferentialthinking.com/chapters/intro +tags: + topics: + - data science + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - book diff --git a/pydis_site/apps/resources/resources/interactive/_category_info.yaml b/pydis_site/apps/resources/resources/interactive/_category_info.yaml deleted file mode 100644 index 7e8f34d9..00000000 --- a/pydis_site/apps/resources/resources/interactive/_category_info.yaml +++ /dev/null @@ -1,4 +0,0 @@ -description: Learn Python with interactive courses, games, and programming challenges. -name: Interactive -default_icon: branding/python -default_icon_color: black diff --git a/pydis_site/apps/resources/resources/interactive/jetbrains_academy.yaml b/pydis_site/apps/resources/resources/jetbrains_academy.yaml index 937831fa..c3cb7657 100644 --- a/pydis_site/apps/resources/resources/interactive/jetbrains_academy.yaml +++ b/pydis_site/apps/resources/resources/jetbrains_academy.yaml @@ -5,4 +5,14 @@ description: Learn Python with a wide range of high quality, project-based lesso It requires a paid subscription, but a free trial is available. name: JetBrains Academy title_url: https://www.jetbrains.com/academy/ -position: 6 +tags: + topics: + - general + - web development + - data science + payment_tiers: + - subscription + difficulty: + - beginner + type: + - interactive diff --git a/pydis_site/apps/resources/resources/videos/jetbrains.yaml b/pydis_site/apps/resources/resources/jetbrains_videos.yaml index 5d130db6..00d34e69 100644 --- a/pydis_site/apps/resources/resources/videos/jetbrains.yaml +++ b/pydis_site/apps/resources/resources/jetbrains_videos.yaml @@ -2,11 +2,20 @@ description: A collection of videos made by the PyCharm team at JetBrains on sub Django, pytest and much more!<br><br> Episodes of their "What does this package do?" series go over all sorts of libraries in Python both in the standard library and from the community and give a video explanation of the key concepts. +name: JetBrains YouTube Channel icon_image: https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/JetBrains_Logo_2016.svg/1200px-JetBrains_Logo_2016.svg.png icon_size: 50 title_image: https://resources.jetbrains.com/storage/products/pycharm/img/meta/pycharm_logo_300x300.png -position: 3 -urls: - - icon: branding/youtube - url: https://www.youtube.com/channel/UCak6beUTLlVmf0E4AmnQkmw - color: youtube-red +title_url: https://www.youtube.com/channel/UCak6beUTLlVmf0E4AmnQkmw +tags: + topics: + - general + - testing + - web development + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - video diff --git a/pydis_site/apps/resources/resources/videos/jim_shaped_coding.yaml b/pydis_site/apps/resources/resources/jim_shaped_coding.yaml index 488cfa83..c9727888 100644 --- a/pydis_site/apps/resources/resources/videos/jim_shaped_coding.yaml +++ b/pydis_site/apps/resources/resources/jim_shaped_coding.yaml @@ -5,9 +5,18 @@ description: 'JimShapedCoding contains a set of YouTube tutorials covering thing <li><a href="https://www.youtube.com/watch?v=qMrAFscMBBc&list=PLOkVupluCIjvORWaF4kG-sXLgbVemYpEi">Django tutorials</a></li> </ul> Check out his channel for more videos!' +name: JimShapedCoding title_image: https://i.imgur.com/DlovZPf.png -position: 5 -urls: - - icon: branding/youtube - url: https://www.youtube.com/channel/UCU8d7rcShA7MGuDyYH1aWGg - color: youtube-red +title_url: https://www.youtube.com/channel/UCU8d7rcShA7MGuDyYH1aWGg +tags: + topics: + - general + - user interface + - web development + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - video diff --git a/pydis_site/apps/resources/resources/kaggle_pandas_tutorial.yaml b/pydis_site/apps/resources/resources/kaggle_pandas_tutorial.yaml new file mode 100644 index 00000000..c8e72c6e --- /dev/null +++ b/pydis_site/apps/resources/resources/kaggle_pandas_tutorial.yaml @@ -0,0 +1,13 @@ +description: An interactive tutorial for learning Pandas, the most popular library for manipulating tabular data + in Python's data science ecosystem. This tutorial assumes some familiarity with writing code in notebooks. +name: Kaggle Pandas Tutorial +title_url: https://www.kaggle.com/learn/pandas +tags: + topics: + - data science + payment_tiers: + - free + difficulty: + - intermediate + type: + - tutorial diff --git a/pydis_site/apps/resources/resources/communities/kivy.yaml b/pydis_site/apps/resources/resources/kivy.yaml index 601d7dba..b1f57483 100644 --- a/pydis_site/apps/resources/resources/communities/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. @@ -5,14 +6,24 @@ icon_image: https://raw.githubusercontent.com/kivy/kivy-website/master/logos/kiv icon_size: 50 title_image: https://i.imgur.com/EVP3jZR.png title_url: https://discord.gg/djPtTRJ -position: 5 urls: + - icon: solid/external-link-alt + url: https://kivy.org/ + color: teal - icon: branding/discord url: https://discord.gg/djPtTRJ color: blurple - - icon: regular/link - url: https://kivy.org/ - color: teal - icon: branding/github url: https://github.com/kivy color: black +tags: + topics: + - user interface + - game development + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - community diff --git a/pydis_site/apps/resources/resources/communities/microsoft.yaml b/pydis_site/apps/resources/resources/microsoft.yaml index b36c3a85..290283cc 100644 --- a/pydis_site/apps/resources/resources/communities/microsoft.yaml +++ b/pydis_site/apps/resources/resources/microsoft.yaml @@ -1,12 +1,20 @@ +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 -title_url: https://discord.gg/b8YJQPx -position: 1 +title_url: https://www.microsoft.com/en-us/boards/pycon2020.aspx urls: - icon: branding/discord url: https://discord.gg/b8YJQPx color: blurple - - icon: regular/link - url: https://www.microsoft.com/en-us/boards/pycon2020.aspx - color: teal +tags: + topics: + - general + - tooling + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - community diff --git a/pydis_site/apps/resources/resources/videos/microsoft.yaml b/pydis_site/apps/resources/resources/microsoft_videos.yaml index 3ceaa1a2..f45aef63 100644 --- a/pydis_site/apps/resources/resources/videos/microsoft.yaml +++ b/pydis_site/apps/resources/resources/microsoft_videos.yaml @@ -7,12 +7,20 @@ description: A trove of tutorials & guides for developers from Microsoft's Devel </ul> Microsoft's Python Development Team also runs a Discord Server for discussions of Python in the Microsoft ecosystem, including Visual Studio Code and Azure. -title_image: https://img-prod-cms-rt-microsoft-com.akamaized.net/cms/api/am/imageFileData/RE2qVsJ?ver=3f74 -position: 4 +name: Microsoft Developer +title_image: https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg +title_url: https://www.youtube.com/channel/UCsMica-v34Irf9KVTh6xx-g urls: - - icon: branding/youtube - url: https://www.youtube.com/channel/UCsMica-v34Irf9KVTh6xx-g - color: youtube-red - icon: branding/discord url: https://aka.ms/python-discord color: blurple +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + type: + - video + - community diff --git a/pydis_site/apps/resources/resources/reading/books/mission_python.yaml b/pydis_site/apps/resources/resources/mission_python.yaml index c4a48b7e..391a2983 100644 --- a/pydis_site/apps/resources/resources/reading/books/mission_python.yaml +++ b/pydis_site/apps/resources/resources/mission_python.yaml @@ -3,11 +3,18 @@ description: Learn programming and Python while building a complete and awesome images, and walk-throughs make this a pleasure to both read and follow along. Excellent book for beginners. name: Mission Python -position: 5 +title_url: https://www.sean.co.uk/books/mission-python/index.shtm urls: -- icon: regular/link - url: https://www.sean.co.uk/books/mission-python/index.shtm - color: teal -- icon: branding/amazon - url: https://www.amazon.com/Mission-Python-Code-Space-Adventure/dp/1593278578 - color: amazon-orange +- icon: branding/goodreads + url: https://www.goodreads.com/book/show/35545850-mission-python + color: black +tags: + topics: + - general + - game development + payment_tiers: + - paid + difficulty: + - beginner + type: + - book diff --git a/pydis_site/apps/resources/resources/courses/mit_introduction_to_computer_science_and_programming.yaml b/pydis_site/apps/resources/resources/mit_introduction_to_computer_science_and_programming.yaml index 5560b2cb..4e74936d 100644 --- a/pydis_site/apps/resources/resources/courses/mit_introduction_to_computer_science_and_programming.yaml +++ b/pydis_site/apps/resources/resources/mit_introduction_to_computer_science_and_programming.yaml @@ -3,4 +3,14 @@ description: This MITx offering teaches computer science with Python. and the Python programming language itself. name: 'MIT: Introduction to Computer Science and Programming' title_url: https://www.edx.org/course/introduction-computer-science-mitx-6-00-1x-11 -position: 1 +tags: + topics: + - general + - algorithms and data structures + payment_tiers: + - free + - paid + difficulty: + - beginner + type: + - course diff --git a/pydis_site/apps/resources/resources/tools/editors/mu_editor.yaml b/pydis_site/apps/resources/resources/mu_editor.yaml index b92bac9d..b6318d0e 100644 --- a/pydis_site/apps/resources/resources/tools/editors/mu_editor.yaml +++ b/pydis_site/apps/resources/resources/mu_editor.yaml @@ -4,4 +4,12 @@ description: An editor aimed at beginners for the purpose of learning how to cod with built-in tools to interact with Adafruit and Arduino boards. name: Mu-Editor title_url: https://codewith.mu/ -position: 3 +tags: + topics: + - microcontrollers + payment_tiers: + - free + difficulty: + - beginner + type: + - tool diff --git a/pydis_site/apps/resources/resources/netbats_project_ideas.yaml b/pydis_site/apps/resources/resources/netbats_project_ideas.yaml new file mode 100644 index 00000000..80ba771c --- /dev/null +++ b/pydis_site/apps/resources/resources/netbats_project_ideas.yaml @@ -0,0 +1,14 @@ +description: A repository of project ideas to help one apply what they're learning, maintained by Python + community member Ned Batchelder, known on Python Discord as nedbat. +name: Ned Batchelder's Kindling Projects +title_url: https://nedbatchelder.com/text/kindling.html +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - project ideas diff --git a/pydis_site/apps/resources/resources/reading/books/neural_networks_from_scratch_in_python.yaml b/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml index 974b0e50..26e88cb9 100644 --- a/pydis_site/apps/resources/resources/reading/books/neural_networks_from_scratch_in_python.yaml +++ b/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml @@ -2,9 +2,18 @@ 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 -position: 11 +name: Neural Networks from Scratch +title_url: https://nnfs.io/ urls: - - icon: regular/link - url: https://nnfs.io/ - color: teal + - icon: branding/goodreads + url: https://www.goodreads.com/book/show/55927899-neural-networks-from-scratch-in-python + color: black +tags: + topics: + - data science + payment_tiers: + - paid + difficulty: + - intermediate + type: + - book diff --git a/pydis_site/apps/resources/resources/communities/pallets.yaml b/pydis_site/apps/resources/resources/pallets.yaml index 239b1491..a330b756 100644 --- a/pydis_site/apps/resources/resources/communities/pallets.yaml +++ b/pydis_site/apps/resources/resources/pallets.yaml @@ -1,13 +1,20 @@ +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. title_image: https://i.imgur.com/sV9Ypdf.png -title_url: https://discord.gg/t6rrQZH -position: 6 +title_url: https://www.palletsprojects.com/ urls: - icon: branding/discord url: https://discord.gg/t6rrQZH color: blurple - - icon: regular/link - url: https://www.palletsprojects.com/ - color: teal +tags: + topics: + - web development + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - community diff --git a/pydis_site/apps/resources/resources/communities/panda3d.yaml b/pydis_site/apps/resources/resources/panda3d.yaml index 47797882..eeb54465 100644 --- a/pydis_site/apps/resources/resources/communities/panda3d.yaml +++ b/pydis_site/apps/resources/resources/panda3d.yaml @@ -1,12 +1,24 @@ +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 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 - - icon: regular/link - url: https://www.panda3d.org/ - color: teal +tags: + topics: + - user interface + - game development + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - community diff --git a/pydis_site/apps/resources/resources/communities/people_postgres_data.yaml b/pydis_site/apps/resources/resources/people_postgres_data.yaml index 1c17d343..9fec6634 100644 --- a/pydis_site/apps/resources/resources/communities/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. @@ -5,14 +6,23 @@ description: People, Postgres, Data specializes in building users of Postgres 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 -position: 2 urls: + - icon: solid/external-link-alt + url: https://postgresconf.org/ + color: teal - icon: branding/discord url: https://discord.gg/Ujw8m8v color: bluple - - icon: regular/link - url: https://postgresconf.org/ - color: teal - icon: branding/reddit url: https://reddit.com/r/postgresql color: orangered +tags: + topics: + - databases + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - community diff --git a/pydis_site/apps/resources/resources/podcasts/podcast_dunder_init.yaml b/pydis_site/apps/resources/resources/podcast_dunder_init.yaml index efe1601f..2751481a 100644 --- a/pydis_site/apps/resources/resources/podcasts/podcast_dunder_init.yaml +++ b/pydis_site/apps/resources/resources/podcast_dunder_init.yaml @@ -2,4 +2,13 @@ description: The podcast about Python and the people who make it great. Weekly l interviews with the creators of notable Python packages. name: Podcast.__init__ title_url: https://www.podcastinit.com/ -position: 2 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - podcast diff --git a/pydis_site/apps/resources/resources/podcasts/_category_info.yaml b/pydis_site/apps/resources/resources/podcasts/_category_info.yaml deleted file mode 100644 index 1d2d3ba5..00000000 --- a/pydis_site/apps/resources/resources/podcasts/_category_info.yaml +++ /dev/null @@ -1,4 +0,0 @@ -description: Notable podcasts about the Python ecosystem. -name: Podcasts -default_icon: regular/microphone-alt -default_icon_color: black diff --git a/pydis_site/apps/resources/resources/courses/practical_python_programming.yaml b/pydis_site/apps/resources/resources/practical_python_programming.yaml index b801ca8c..12873b7c 100644 --- a/pydis_site/apps/resources/resources/courses/practical_python_programming.yaml +++ b/pydis_site/apps/resources/resources/practical_python_programming.yaml @@ -7,3 +7,12 @@ description: Created and taught by <a href="https://dabeaz.com/">David Beazley</ name: Practical Python Programming title_url: https://dabeaz-course.github.io/practical-python/ position: 4 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + type: + - course diff --git a/pydis_site/apps/resources/resources/pycharm.yaml b/pydis_site/apps/resources/resources/pycharm.yaml new file mode 100644 index 00000000..e8c787e6 --- /dev/null +++ b/pydis_site/apps/resources/resources/pycharm.yaml @@ -0,0 +1,15 @@ +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: + - general + payment_tiers: + - free + - paid + difficulty: + - intermediate + type: + - tool diff --git a/pydis_site/apps/resources/resources/communities/pyglet.yaml b/pydis_site/apps/resources/resources/pyglet.yaml index 784f514e..bdfb84cf 100644 --- a/pydis_site/apps/resources/resources/communities/pyglet.yaml +++ b/pydis_site/apps/resources/resources/pyglet.yaml @@ -1,15 +1,23 @@ +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, loading images and videos, and playing sounds and music. All of this with a friendly Pythonic API, that's simple to learn and doesn't get in your way. title_image: https://i.imgur.com/LfQwXUe.png -title_url: https://discord.gg/QXyegWe -position: 8 +title_url: http://pyglet.org/ urls: - icon: branding/discord url: https://discord.gg/QXyegWe color: blurple - - icon: regular/link - url: http://pyglet.org/ - color: teal +tags: + topics: + - user interface + - game development + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - community diff --git a/pydis_site/apps/resources/resources/podcasts/python_bytes.yaml b/pydis_site/apps/resources/resources/python_bytes.yaml index 4f817f26..9beba4f4 100644 --- a/pydis_site/apps/resources/resources/podcasts/python_bytes.yaml +++ b/pydis_site/apps/resources/resources/python_bytes.yaml @@ -3,3 +3,13 @@ description: A byte-sized podcast where Michael Kennedy and Brian Okken work thr name: Python Bytes title_url: https://pythonbytes.fm/ position: 1 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - podcast diff --git a/pydis_site/apps/resources/resources/reading/tutorials/python_cheat_sheet.yaml b/pydis_site/apps/resources/resources/python_cheat_sheet.yaml index 70ac49ef..56f61165 100644 --- a/pydis_site/apps/resources/resources/reading/tutorials/python_cheat_sheet.yaml +++ b/pydis_site/apps/resources/resources/python_cheat_sheet.yaml @@ -2,4 +2,12 @@ description: A Python 3 cheat sheet with useful information and tips, as well as pitfalls for beginners. This is a PDF. name: Python Cheat Sheet title_url: https://perso.limsi.fr/pointal/_media/python:cours:mementopython3-english.pdf -position: 6 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + type: + - tutorial diff --git a/pydis_site/apps/resources/resources/python_cookbook.yaml b/pydis_site/apps/resources/resources/python_cookbook.yaml new file mode 100644 index 00000000..bc05d743 --- /dev/null +++ b/pydis_site/apps/resources/resources/python_cookbook.yaml @@ -0,0 +1,21 @@ +description: A book full of very smart problem-solving recipes for various Python topics, + including moving from Python 2 to Python 3. +name: Python Cookbook +title_url: http://shop.oreilly.com/product/0636920027072.do +urls: +- icon: branding/goodreads + url: https://www.goodreads.com/book/show/17152735-python-cookbook + color: black +- icon: branding/github + url: https://github.com/dabeaz/python-cookbook + color: black +tags: + topics: + - general + - software design + payment_tiers: + - paid + difficulty: + - intermediate + type: + - book diff --git a/pydis_site/apps/resources/resources/reading/books/python_crash_course.yaml b/pydis_site/apps/resources/resources/python_crash_course.yaml index 3cbf19c8..d916075e 100644 --- a/pydis_site/apps/resources/resources/reading/books/python_crash_course.yaml +++ b/pydis_site/apps/resources/resources/python_crash_course.yaml @@ -7,14 +7,21 @@ description: "This fast-paced, thorough introduction to programming with Python a Space Invaders–inspired arcade game, a set of data visualizations with Python’s handy libraries, and a simple web app you can deploy online." name: Python Crash Course -position: 12 +title_url: https://nostarch.com/pythoncrashcourse2e urls: - - icon: regular/link - url: https://nostarch.com/pythoncrashcourse2e - color: teal - - icon: branding/amazon - url: https://www.amazon.com/Python-Crash-Course-Project-Based-Introduction/dp/1593276036 - color: amazon-orange + - icon: branding/goodreads + url: https://www.goodreads.com/book/show/23241059-python-crash-course + color: black - icon: branding/github url: https://ehmatthes.github.io/pcc/ color: black +tags: + topics: + - general + - game development + payment_tiers: + - paid + difficulty: + - beginner + type: + - book diff --git a/pydis_site/apps/resources/resources/reading/tutorials/python_developer_guide.yaml b/pydis_site/apps/resources/resources/python_developer_guide.yaml index 625d57c8..2806d75d 100644 --- a/pydis_site/apps/resources/resources/reading/tutorials/python_developer_guide.yaml +++ b/pydis_site/apps/resources/resources/python_developer_guide.yaml @@ -2,4 +2,12 @@ description: This guide is a comprehensive resource for contributing to Python � It is maintained by the same community that maintains Python. name: Python Developer's Guide title_url: https://devguide.python.org/ -position: 2 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - intermediate + type: + - tutorial diff --git a/pydis_site/apps/resources/resources/python_discord_videos.yaml b/pydis_site/apps/resources/resources/python_discord_videos.yaml new file mode 100644 index 00000000..012ec8ea --- /dev/null +++ b/pydis_site/apps/resources/resources/python_discord_videos.yaml @@ -0,0 +1,16 @@ +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 +title_url: https://www.youtube.com/pythondiscord +tags: + topics: + - general + - software design + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - video diff --git a/pydis_site/apps/resources/resources/interactive/python_morsels.yaml b/pydis_site/apps/resources/resources/python_morsels.yaml index 879500eb..4cdff36b 100644 --- a/pydis_site/apps/resources/resources/interactive/python_morsels.yaml +++ b/pydis_site/apps/resources/resources/python_morsels.yaml @@ -7,4 +7,14 @@ description: 'Learn to write more idiomatic Python code with deliberate practice tests and some may include bonuses for a little more of a challenge!' name: Python Morsels title_url: https://www.pythonmorsels.com/ -position: 3 +tags: + topics: + - general + - software design + payment_tiers: + - subscription + difficulty: + - intermediate + type: + - interactive + - video diff --git a/pydis_site/apps/resources/resources/communities/subreddit.yaml b/pydis_site/apps/resources/resources/python_subreddit.yaml index d3ddb15a..e94f84fc 100644 --- a/pydis_site/apps/resources/resources/communities/subreddit.yaml +++ b/pydis_site/apps/resources/resources/python_subreddit.yaml @@ -4,3 +4,13 @@ title_icon: branding/reddit title_icon_color: orangered title_url: https://www.reddit.com/r/Python/ position: 0 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - community diff --git a/pydis_site/apps/resources/resources/python_tricks.yaml b/pydis_site/apps/resources/resources/python_tricks.yaml new file mode 100644 index 00000000..aa1b2fcd --- /dev/null +++ b/pydis_site/apps/resources/resources/python_tricks.yaml @@ -0,0 +1,19 @@ +description: Full of useful Python tips, tricks and features. Get this if you have + a good grasp of the basics and want to take your Python skills to the next level, + or are a experienced programmer looking to add to your toolbelt. +name: Python Tricks +title_url: https://realpython.com/products/python-tricks-book/ +urls: +- icon: branding/goodreads + url: https://www.goodreads.com/book/show/36990732-python-tricks + color: black +tags: + topics: + - general + - software design + payment_tiers: + - paid + difficulty: + - intermediate + type: + - book diff --git a/pydis_site/apps/resources/resources/interactive/python_tutor.yaml b/pydis_site/apps/resources/resources/python_tutor.yaml index 64b50d09..6bee0d69 100644 --- a/pydis_site/apps/resources/resources/interactive/python_tutor.yaml +++ b/pydis_site/apps/resources/resources/python_tutor.yaml @@ -1,4 +1,14 @@ description: Write Python code in your web browser, and see it visualized step by step. name: Python Tutor title_url: https://www.pythontutor.com/ -position: 2 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tool + - interactive diff --git a/pydis_site/apps/resources/resources/reading/_category_info.yaml b/pydis_site/apps/resources/resources/reading/_category_info.yaml deleted file mode 100644 index 64b87e47..00000000 --- a/pydis_site/apps/resources/resources/reading/_category_info.yaml +++ /dev/null @@ -1,2 +0,0 @@ -description: Books and tutorials related to Python and popular third-party libraries and frameworks. -name: Reading diff --git a/pydis_site/apps/resources/resources/reading/books/_category_info.yaml b/pydis_site/apps/resources/resources/reading/books/_category_info.yaml deleted file mode 100644 index ae092a20..00000000 --- a/pydis_site/apps/resources/resources/reading/books/_category_info.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: The best books for learning Python or Python Frameworks. -name: Books -default_icon: branding/python -default_icon_color: black -position: 0 diff --git a/pydis_site/apps/resources/resources/reading/books/effective_python.yaml b/pydis_site/apps/resources/resources/reading/books/effective_python.yaml deleted file mode 100644 index becd0578..00000000 --- a/pydis_site/apps/resources/resources/reading/books/effective_python.yaml +++ /dev/null @@ -1,15 +0,0 @@ -description: A book that gives 90 best practices for writing excellent Python. Great - for intermediates. -name: Effective Python -position: 3 -urls: -- icon: regular/link - url: https://effectivepython.com/ - color: teal -- icon: branding/amazon - url: https://www.amazon.com/Effective-Python-Specific-Software-Development/dp/0134853989 - color: amazon-orange - title: Amazon -- icon: branding/github - url: https://github.com/bslatkin/effectivepython - color: black diff --git a/pydis_site/apps/resources/resources/reading/books/flask_web_development.yaml b/pydis_site/apps/resources/resources/reading/books/flask_web_development.yaml deleted file mode 100644 index d191f02d..00000000 --- a/pydis_site/apps/resources/resources/reading/books/flask_web_development.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: A comprehensive Flask walkthrough that has you building a complete social - blogging application from scratch. -name: Flask Web Development -position: 6 -urls: -- icon: regular/link - url: https://shop.oreilly.com/product/0636920031116.do - color: teal -- icon: branding/amazon - url: https://www.amazon.com/Flask-Web-Development-Developing-Applications/dp/1449372627 - color: amazon-orange -- icon: branding/github - url: https://github.com/miguelgrinberg/flasky - color: black diff --git a/pydis_site/apps/resources/resources/reading/books/fluent_python.yaml b/pydis_site/apps/resources/resources/reading/books/fluent_python.yaml deleted file mode 100644 index 92f4bbab..00000000 --- a/pydis_site/apps/resources/resources/reading/books/fluent_python.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: A veritable tome of intermediate and advanced Python information. A must-read - for any Python professional. By far the most recommended book for intermediates. -name: Fluent Python -position: 7 -urls: -- icon: regular/link - url: https://www.oreilly.com/library/view/fluent-python/9781491946237/ - color: teal -- icon: branding/amazon - url: https://www.amazon.com/Fluent-Python-Concise-Effective-Programming/dp/1491946008 - color: amazon-orange -- icon: branding/github - url: https://github.com/fluentpython - color: black diff --git a/pydis_site/apps/resources/resources/reading/books/hitchhikers_guide_to_python.yaml b/pydis_site/apps/resources/resources/reading/books/hitchhikers_guide_to_python.yaml deleted file mode 100644 index 906860c7..00000000 --- a/pydis_site/apps/resources/resources/reading/books/hitchhikers_guide_to_python.yaml +++ /dev/null @@ -1,11 +0,0 @@ -description: A best practice handbook for both novice and expert Python developers to the installation, - configuration, and usage of Python on a daily basis. -name: The Hitchhiker's Guide to Python -position: 0 -urls: -- icon: regular/link - url: https://python-guide.org/ - color: teal -- icon: branding/amazon - url: https://www.amazon.com/Hitchhikers-Guide-Python-Practices-Development/dp/1491933178/ - color: amazon-orange diff --git a/pydis_site/apps/resources/resources/reading/books/python_cookbook.yaml b/pydis_site/apps/resources/resources/reading/books/python_cookbook.yaml deleted file mode 100644 index c939ab9e..00000000 --- a/pydis_site/apps/resources/resources/reading/books/python_cookbook.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: A book full of very smart problem-solving recipes for various Python topics, - including moving from Python 2 to Python 3. -name: Python Cookbook -position: 8 -urls: -- icon: regular/link - url: https://shop.oreilly.com/product/0636920027072.do - color: teal -- icon: branding/amazon - url: https://www.amazon.com/Python-Cookbook-Third-David-Beazley/dp/1449340377 - color: amazon-orange -- icon: branding/github - url: https://github.com/dabeaz/python-cookbook - color: black diff --git a/pydis_site/apps/resources/resources/reading/books/python_tricks.yaml b/pydis_site/apps/resources/resources/reading/books/python_tricks.yaml deleted file mode 100644 index c0941809..00000000 --- a/pydis_site/apps/resources/resources/reading/books/python_tricks.yaml +++ /dev/null @@ -1,12 +0,0 @@ -description: Full of useful Python tips, tricks and features. Get this if you have - a good grasp of the basics and want to take your Python skills to the next level, - or are a experienced programmer looking to add to your toolbelt. -name: Python Tricks -position: 4 -urls: -- icon: regular/link - url: https://realpython.com/products/python-tricks-book/ - color: teal -- icon: branding/amazon - url: https://www.amazon.com/Python-Tricks-Buffet-Awesome-Features/dp/1775093301 - color: amazon-orange diff --git a/pydis_site/apps/resources/resources/reading/books/two_scoops_of_django.yaml b/pydis_site/apps/resources/resources/reading/books/two_scoops_of_django.yaml deleted file mode 100644 index 7d83e7c4..00000000 --- a/pydis_site/apps/resources/resources/reading/books/two_scoops_of_django.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: Tips, tricks, and best practices for your Django project. - A highly recommended resource for Django web developers. -name: Two Scoops of Django -position: 9 -urls: -- icon: regular/link - url: https://twoscoopspress.com/products/two-scoops-of-django-1-11 - color: teal -- icon: branding/amazon - url: https://www.amazon.com/Two-Scoops-Django-Best-Practices/dp/0981467342 - color: amazon-orange -- icon: branding/github - url: https://github.com/twoscoops/two-scoops-of-django-2.0-code-examples - color: black diff --git a/pydis_site/apps/resources/resources/reading/tutorials/_category_info.yaml b/pydis_site/apps/resources/resources/reading/tutorials/_category_info.yaml deleted file mode 100644 index a18b837d..00000000 --- a/pydis_site/apps/resources/resources/reading/tutorials/_category_info.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: Tutorials and references for those that are just getting started with Python. -name: Tutorials -default_icon: branding/python -default_icon_color: black -position: 1 diff --git a/pydis_site/apps/resources/resources/communities/real_python.yaml b/pydis_site/apps/resources/resources/real_python.yaml index 1fc74d93..93953004 100644 --- a/pydis_site/apps/resources/resources/communities/real_python.yaml +++ b/pydis_site/apps/resources/resources/real_python.yaml @@ -1,12 +1,22 @@ +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 title_url: https://realpython.com/ position: 3 urls: - - icon: regular/link - url: https://realpython.com/ - color: teal - icon: branding/youtube url: https://www.youtube.com/channel/UCI0vQvr9aFn27yR6Ej6n5UA color: youtube-red +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tutorial + - video + - community diff --git a/pydis_site/apps/resources/resources/regex101.yaml b/pydis_site/apps/resources/resources/regex101.yaml new file mode 100644 index 00000000..45d00f1b --- /dev/null +++ b/pydis_site/apps/resources/resources/regex101.yaml @@ -0,0 +1,15 @@ +description: An online tool for testing regular expressions that helps you understand what the regular expression can + match. Remember to set the "flavor" to Python. +name: regex101 +title_url: https://regex101.com/ +tags: + topics: + - general + - other + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tool diff --git a/pydis_site/apps/resources/resources/repl_it.yaml b/pydis_site/apps/resources/resources/repl_it.yaml new file mode 100644 index 00000000..e0f6cbb3 --- /dev/null +++ b/pydis_site/apps/resources/resources/repl_it.yaml @@ -0,0 +1,14 @@ +description: A free, collaborative, in-browser IDE to code in 50+ languages — + without spending a second on setup. +name: repl.it +title_url: https://repl.it/ +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tool diff --git a/pydis_site/apps/resources/resources/tools/accessibility_tools/screen_readers.yaml b/pydis_site/apps/resources/resources/screen_readers.yaml index 39372956..b086b301 100644 --- a/pydis_site/apps/resources/resources/tools/accessibility_tools/screen_readers.yaml +++ b/pydis_site/apps/resources/resources/screen_readers.yaml @@ -4,4 +4,14 @@ description: Screen readers are software programs that allow blind with this link describing many of them and their capabilities. name: Screen Readers - American Foundation for the Blind title_url: https://www.afb.org/blindness-and-low-vision/using-technology/assistive-technology-products/screen-readers -position: 1 +tags: + topics: + - other + payment_tiers: + - free + - paid + difficulty: + - beginner + - intermediate + type: + - tool diff --git a/pydis_site/apps/resources/resources/videos/sentdex.yaml b/pydis_site/apps/resources/resources/sentdex.yaml index 4e5f54c6..7cb0a8a4 100644 --- a/pydis_site/apps/resources/resources/videos/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. <ul> @@ -9,14 +10,24 @@ description: 'An enormous amount of Python content for all skill levels Check out his channel for more video series! ' title_image: https://i.imgur.com/kJgWZIu.png -position: 1 +title_url: https://www.youtube.com/user/sentdex urls: - - icon: branding/youtube - url: https://www.youtube.com/user/sentdex - color: youtube-red + - icon: solid/external-link-alt + url: https://pythonprogramming.net/ + color: teal - icon: branding/discord url: https://discord.gg/sentdex color: blurple - - icon: regular/link - url: https://pythonprogramming.net/ - color: teal +tags: + topics: + - general + - user interface + - data science + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - video + - community diff --git a/pydis_site/apps/resources/resources/reading/tutorials/simple_guide_to_git.yaml b/pydis_site/apps/resources/resources/simple_guide_to_git.yaml index 9d151bf9..3bb46e6d 100644 --- a/pydis_site/apps/resources/resources/reading/tutorials/simple_guide_to_git.yaml +++ b/pydis_site/apps/resources/resources/simple_guide_to_git.yaml @@ -3,4 +3,12 @@ name: A Simple Guide to Git title_url: https://rogerdudler.github.io/git-guide/ title_icon: branding/github title_icon_color: black -position: 4 +tags: + topics: + - tooling + payment_tiers: + - free + difficulty: + - beginner + type: + - tutorial diff --git a/pydis_site/apps/resources/resources/socratica.yaml b/pydis_site/apps/resources/resources/socratica.yaml new file mode 100644 index 00000000..45150b33 --- /dev/null +++ b/pydis_site/apps/resources/resources/socratica.yaml @@ -0,0 +1,23 @@ +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.' +title_image: https://i.imgur.com/4SoHeLz.png +title_url: https://www.youtube.com/playlist?list=PLi01XoE8jYohWFPpC17Z-wWhPOSuh8Er- +urls: + - icon: solid/database + url: https://www.youtube.com/playlist?list=PLi01XoE8jYojRqM4qGBF1U90Ee1Ecb5tt + color: teal + - icon: branding/youtube + url: https://www.youtube.com/channel/UCW6TXMZ5Pq6yL6_k5NZ2e0Q + color: youtube-red +tags: + topics: + - general + - databases + payment_tiers: + - free + difficulty: + - beginner + type: + - video diff --git a/pydis_site/apps/resources/resources/interactive/sololearn.yaml b/pydis_site/apps/resources/resources/sololearn.yaml index 51dceb2a..998f5368 100644 --- a/pydis_site/apps/resources/resources/interactive/sololearn.yaml +++ b/pydis_site/apps/resources/resources/sololearn.yaml @@ -4,4 +4,14 @@ description: SoloLearn's Python 3 course serves as a simple and convenient intro and mobile apps being available to use. name: SoloLearn title_url: https://www.sololearn.com/Course/Python/ -position: 4 +tags: + topics: + - general + payment_tiers: + - free + - subscription + difficulty: + - beginner + type: + - interactive + - course diff --git a/pydis_site/apps/resources/resources/tools/ides/spyder.yaml b/pydis_site/apps/resources/resources/spyder.yaml index c2f9c2dc..668e9306 100644 --- a/pydis_site/apps/resources/resources/tools/ides/spyder.yaml +++ b/pydis_site/apps/resources/resources/spyder.yaml @@ -2,4 +2,13 @@ description: The Scientific Python Development Environment. Simpler and lighter than PyCharm, but still packs a punch. name: Spyder title_url: https://www.spyder-ide.org/ -position: 1 +tags: + topics: + - data science + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tool diff --git a/pydis_site/apps/resources/resources/tools/editors/sublime_text.yaml b/pydis_site/apps/resources/resources/sublime_text.yaml index 3c6e7e84..05596477 100644 --- a/pydis_site/apps/resources/resources/tools/editors/sublime_text.yaml +++ b/pydis_site/apps/resources/resources/sublime_text.yaml @@ -2,4 +2,13 @@ description: A powerful Python-backed editor with great community support and a of extensions. name: Sublime Text title_url: https://www.sublimetext.com/ -position: 2 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tool diff --git a/pydis_site/apps/resources/resources/podcasts/talk_python_to_me.yaml b/pydis_site/apps/resources/resources/talk_python_to_me.yaml index 5ce21fd7..509922c3 100644 --- a/pydis_site/apps/resources/resources/podcasts/talk_python_to_me.yaml +++ b/pydis_site/apps/resources/resources/talk_python_to_me.yaml @@ -2,4 +2,13 @@ description: The essential weekly Python podcast. Michael Kennedy and a prominen name within the Python community dive into a topic that relates to their experience. name: Talk Python To Me title_url: https://talkpython.fm/ -position: 0 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - podcast diff --git a/pydis_site/apps/resources/resources/tools/accessibility_tools/talon_voice.yaml b/pydis_site/apps/resources/resources/talon_voice.yaml index 9df5f66f..3be5fe20 100644 --- a/pydis_site/apps/resources/resources/tools/accessibility_tools/talon_voice.yaml +++ b/pydis_site/apps/resources/resources/talon_voice.yaml @@ -3,4 +3,13 @@ description: Talon is a tool being built that aims to bring programming, who have limited or no use of their hands. name: Talon Voice title_url: https://talonvoice.com/ -position: 0 +tags: + topics: + - other + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tool diff --git a/pydis_site/apps/resources/resources/podcasts/test_and_code.yaml b/pydis_site/apps/resources/resources/test_and_code.yaml index d5751577..f0d1c3b3 100644 --- a/pydis_site/apps/resources/resources/podcasts/test_and_code.yaml +++ b/pydis_site/apps/resources/resources/test_and_code.yaml @@ -2,4 +2,14 @@ description: Brian Okken's weekly podcast on testing. Usually deals with Python, but also covers many language-agnostic topics from the testing and DevOps world. name: Test & Code title_url: https://testandcode.com/ -position: 3 +tags: + topics: + - testing + - tooling + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - podcast 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..30a0a5da --- /dev/null +++ b/pydis_site/apps/resources/resources/the_algorithms_github.yaml @@ -0,0 +1,17 @@ +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 diff --git a/pydis_site/apps/resources/resources/reading/tutorials/the_flask_mega_tutorial.yaml b/pydis_site/apps/resources/resources/the_flask_mega_tutorial.yaml index 8d61ea73..151768a5 100644 --- a/pydis_site/apps/resources/resources/reading/tutorials/the_flask_mega_tutorial.yaml +++ b/pydis_site/apps/resources/resources/the_flask_mega_tutorial.yaml @@ -1,4 +1,13 @@ description: Miguel Grinberg's fully featured mega-tutorial for learning how to create web applications with the Flask framework. name: The Flask Mega-Tutorial title_url: https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world -position: 5 +tags: + topics: + - web development + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tutorial diff --git a/pydis_site/apps/resources/resources/podcasts/the_real_python_podcast.yaml b/pydis_site/apps/resources/resources/the_real_python_podcast.yaml index dea894ea..647779d5 100644 --- a/pydis_site/apps/resources/resources/podcasts/the_real_python_podcast.yaml +++ b/pydis_site/apps/resources/resources/the_real_python_podcast.yaml @@ -4,4 +4,13 @@ description: A weekly Python podcast hosted by Christopher Bailey with interview career tips, and related software development topics. name: The Real Python Podcast title_url: https://realpython.com/podcasts/rpp/ -position: 4 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - podcast diff --git a/pydis_site/apps/resources/resources/reading/books/think_python.yaml b/pydis_site/apps/resources/resources/think_python.yaml index 6de87043..7099afd8 100644 --- a/pydis_site/apps/resources/resources/reading/books/think_python.yaml +++ b/pydis_site/apps/resources/resources/think_python.yaml @@ -4,14 +4,21 @@ description: Think Python is an introduction to Python programming for beginners Larger pieces, like recursion and object-oriented programming are divided into a sequence of smaller steps and introduced over the course of several chapters. name: Think Python -position: 10 +title_url: https://greenteapress.com/wp/think-python-2e/ urls: - - icon: regular/link - url: https://greenteapress.com/wp/think-python-2e/ - color: teal - - icon: branding/amazon - url: https://www.amazon.com/gp/product/1491939362 - color: amazon-orange + - icon: branding/goodreads + url: https://www.goodreads.com/book/show/14514306-think-python + color: black - icon: branding/github url: https://github.com/AllenDowney/ThinkPython2 color: black +tags: + topics: + - general + - software design + payment_tiers: + - paid + difficulty: + - beginner + type: + - book diff --git a/pydis_site/apps/resources/resources/tools/ides/thonny.yaml b/pydis_site/apps/resources/resources/thonny.yaml index d7f03a74..29ba9e07 100644 --- a/pydis_site/apps/resources/resources/tools/ides/thonny.yaml +++ b/pydis_site/apps/resources/resources/thonny.yaml @@ -3,3 +3,12 @@ description: A Python IDE specifically aimed at learning programming. Has a lot name: Thonny title_url: https://thonny.org/ position: 2 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + type: + - tool diff --git a/pydis_site/apps/resources/resources/tools/_category_info.yaml b/pydis_site/apps/resources/resources/tools/_category_info.yaml deleted file mode 100644 index 6b16baa6..00000000 --- a/pydis_site/apps/resources/resources/tools/_category_info.yaml +++ /dev/null @@ -1,4 +0,0 @@ -description: This page is a curated list of tools that we regularly recommend in the community. - If you have a suggestion for something to add to this page, please create an issue in - <a href="https://github.com/python-discord/meta/issues">our meta repo</a>, and we'll consider adding it. -name: Tools diff --git a/pydis_site/apps/resources/resources/tools/accessibility_tools/_category_info.yaml b/pydis_site/apps/resources/resources/tools/accessibility_tools/_category_info.yaml deleted file mode 100644 index e770db07..00000000 --- a/pydis_site/apps/resources/resources/tools/accessibility_tools/_category_info.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: Accessibility tools that help people write Python code. -name: Accessibility Tools -default_icon: branding/python -default_icon_color: black -position: 2 diff --git a/pydis_site/apps/resources/resources/tools/editors/_category_info.yaml b/pydis_site/apps/resources/resources/tools/editors/_category_info.yaml deleted file mode 100644 index 3cdfff3a..00000000 --- a/pydis_site/apps/resources/resources/tools/editors/_category_info.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: Lightweight code editors supporting Python -name: Editors -default_icon: branding/python -default_icon_color: black -position: 1 diff --git a/pydis_site/apps/resources/resources/tools/editors/atom.yaml b/pydis_site/apps/resources/resources/tools/editors/atom.yaml deleted file mode 100644 index c44f9b5b..00000000 --- a/pydis_site/apps/resources/resources/tools/editors/atom.yaml +++ /dev/null @@ -1,5 +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/ -position: 0 diff --git a/pydis_site/apps/resources/resources/tools/editors/google_collab.yaml b/pydis_site/apps/resources/resources/tools/editors/google_collab.yaml deleted file mode 100644 index 302c3e2e..00000000 --- a/pydis_site/apps/resources/resources/tools/editors/google_collab.yaml +++ /dev/null @@ -1,7 +0,0 @@ -description: Google Collab is a high-powered custom version of Jupyter Notebook which supports e.g. - !apt-get to install arbitrary Debian packages to the runtime, which is very generous with CPU and memory, - and well-integrated with Google Drive. - You can share your Collab Notebooks with other people and work collaboratively. -name: Google Collab -title_url: https://colab.research.google.com/notebooks/intro.ipynb -position: 4 diff --git a/pydis_site/apps/resources/resources/tools/ides/_category_info.yaml b/pydis_site/apps/resources/resources/tools/ides/_category_info.yaml deleted file mode 100644 index 614625a6..00000000 --- a/pydis_site/apps/resources/resources/tools/ides/_category_info.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: Fully-integrated development environments for serious Python work. -name: IDEs -default_icon: branding/python -default_icon_color: black -position: 0 diff --git a/pydis_site/apps/resources/resources/tools/ides/pycharm.yaml b/pydis_site/apps/resources/resources/tools/ides/pycharm.yaml deleted file mode 100644 index b959b0f8..00000000 --- a/pydis_site/apps/resources/resources/tools/ides/pycharm.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: The very best Python IDE, with a wealth of advanced features and convenience - functions. -name: PyCharm -title_url: https://www.jetbrains.com/pycharm/ -position: 0 diff --git a/pydis_site/apps/resources/resources/tools/ides/replit.yaml b/pydis_site/apps/resources/resources/tools/ides/replit.yaml deleted file mode 100644 index 844c5016..00000000 --- a/pydis_site/apps/resources/resources/tools/ides/replit.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: A free, collaborative, in-browser IDE to code in 50+ languages — - without spending a second on setup. -name: replit -title_url: https://replit.com/ -position: 3 diff --git a/pydis_site/apps/resources/resources/two_scoops_of_django.yaml b/pydis_site/apps/resources/resources/two_scoops_of_django.yaml new file mode 100644 index 00000000..f372d35d --- /dev/null +++ b/pydis_site/apps/resources/resources/two_scoops_of_django.yaml @@ -0,0 +1,20 @@ +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/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 + color: black +- icon: branding/github + url: https://github.com/twoscoops/two-scoops-of-django-2.0-code-examples + color: black +tags: + topics: + - web development + payment_tiers: + - paid + difficulty: + - intermediate + type: + - book diff --git a/pydis_site/apps/resources/resources/courses/university_of_michigan.yaml b/pydis_site/apps/resources/resources/university_of_michigan.yaml index 3efe7640..7aaaf2ae 100644 --- a/pydis_site/apps/resources/resources/courses/university_of_michigan.yaml +++ b/pydis_site/apps/resources/resources/university_of_michigan.yaml @@ -2,4 +2,12 @@ description: A 5-part specialization course that teaches Python from scratch. The course has no pre-requisites and avoids all but the simplest mathematics. name: 'University of Michigan: Programming for Everybody' title_url: https://www.coursera.org/learn/python -position: 2 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + type: + - course diff --git a/pydis_site/apps/resources/resources/courses/university_of_toronto.yaml b/pydis_site/apps/resources/resources/university_of_toronto.yaml index 0a7839de..94df96f2 100644 --- a/pydis_site/apps/resources/resources/courses/university_of_toronto.yaml +++ b/pydis_site/apps/resources/resources/university_of_toronto.yaml @@ -1,7 +1,6 @@ description: A 2-part course that teaches Python. Primarily intended for high school students and first-year university students who want to learn programming. name: 'University of Toronto: Learn to Program' -position: 0 urls: - icon: regular/graduation-cap url: https://www.coursera.org/learn/learn-to-program @@ -9,3 +8,13 @@ urls: - icon: regular/graduation-cap url: https://www.coursera.org/learn/program-code color: youtube-red +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - course diff --git a/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml b/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml new file mode 100644 index 00000000..12f2a154 --- /dev/null +++ b/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml @@ -0,0 +1,14 @@ +description: This tutorial, written by vcokltfre, + will walk you through all the aspects of creating your own Discord bot, + starting from creating the bot user itself. +name: vcokltfre's Discord Bot Tutorial +title_url: https://tutorial.vcokltfre.dev/ +tags: + topics: + - discord bots + payment_tiers: + - free + difficulty: + - intermediate + type: + - tutorial diff --git a/pydis_site/apps/resources/resources/videos/_category_info.yaml b/pydis_site/apps/resources/resources/videos/_category_info.yaml deleted file mode 100644 index 8192e021..00000000 --- a/pydis_site/apps/resources/resources/videos/_category_info.yaml +++ /dev/null @@ -1,2 +0,0 @@ -description: Excellent Youtube channels with content related to Python. -name: Videos diff --git a/pydis_site/apps/resources/resources/videos/python_discord.yaml b/pydis_site/apps/resources/resources/videos/python_discord.yaml deleted file mode 100644 index 04235b08..00000000 --- a/pydis_site/apps/resources/resources/videos/python_discord.yaml +++ /dev/null @@ -1,8 +0,0 @@ -description: It's our 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 -position: 2 -urls: - - icon: branding/youtube - url: https://www.youtube.com/pythondiscord - color: youtube-red diff --git a/pydis_site/apps/resources/resources/tools/editors/visual_studio_code.yaml b/pydis_site/apps/resources/resources/visual_studio_code.yaml index e3737ca7..3cf858f8 100644 --- a/pydis_site/apps/resources/resources/tools/editors/visual_studio_code.yaml +++ b/pydis_site/apps/resources/resources/visual_studio_code.yaml @@ -1,4 +1,13 @@ description: A fully-featured editor based on Electron, extendable with plugins. name: Visual Studio Code title_url: https://code.visualstudio.com/ -position: 1 +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tool diff --git a/pydis_site/apps/resources/resources/reading/tutorials/wtf_python.yaml b/pydis_site/apps/resources/resources/wtf_python.yaml index a25a84fd..6d90ba39 100644 --- a/pydis_site/apps/resources/resources/reading/tutorials/wtf_python.yaml +++ b/pydis_site/apps/resources/resources/wtf_python.yaml @@ -6,3 +6,13 @@ description: Python, being a beautifully designed high-level and interpreter-bas name: WTF Python title_url: https://github.com/satwikkansal/wtfpython position: 7 +tags: + topics: + - software design + - other + payment_tiers: + - free + difficulty: + - intermediate + type: + - tutorial diff --git a/pydis_site/apps/resources/templatetags/get_category_icon.py b/pydis_site/apps/resources/templatetags/get_category_icon.py new file mode 100644 index 00000000..30bc4eaa --- /dev/null +++ b/pydis_site/apps/resources/templatetags/get_category_icon.py @@ -0,0 +1,40 @@ +from django import template + +register = template.Library() + +_ICONS = { + "Algorithms And Data Structures": "fa-cogs", + "Beginner": "fa-play-circle", + "Book": "fa-book", + "Community": "fa-users", + "Course": "fa-chalkboard-teacher", + "Data Science": "fa-flask", + "Databases": "fa-server", + "Discord Bots": "fa-robot", + "Free": "fa-first-aid", + "Game Development": "fa-gamepad", + "General": "fa-book", + "Interactive": "fa-mouse-pointer", + "Intermediate": "fa-align-center", + "Microcontrollers": "fa-microchip", + "Other": "fa-question-circle", + "Paid": "fa-dollar-sign", + "Podcast": "fa-microphone-alt", + "Project Ideas": "fa-lightbulb-o", + "Security": "fa-solid fa-lock", + "Software Design": "fa-paint-brush", + "Subscription": "fa-credit-card", + "Testing": "fa-vial", + "Tool": "fa-tools", + "Tooling": "fa-toolbox", + "Tutorial": "fa-clipboard-list", + "User Interface": "fa-desktop", + "Video": "fa-video", + "Web Development": "fa-wifi", +} + + +def get_category_icon(name: str) -> str: + """Get icon of a specific resource category.""" + return f'fa {_ICONS[name]}' diff --git a/pydis_site/apps/resources/templatetags/to_kebabcase.py b/pydis_site/apps/resources/templatetags/to_kebabcase.py new file mode 100644 index 00000000..41e2ac85 --- /dev/null +++ b/pydis_site/apps/resources/templatetags/to_kebabcase.py @@ -0,0 +1,39 @@ +import re + +from django import template + +REGEX_CONSECUTIVE_NON_LETTERS = r"[^A-Za-z0-9]+" +register = template.Library() + + +def _to_kebabcase(class_name: str) -> str: + """ + Convert any string to kebab-case. + + For example, convert + "__Favorite FROOT¤#/$?is----LeMON???" to + "favorite-froot-is-lemon" + """ + # First, make it lowercase, and just remove any apostrophes. + # We remove the apostrophes because "wasnt" is better than "wasn-t" + class_name = class_name.casefold() + class_name = class_name.replace("'", '') + + # Now, replace any non-letter that remains with a dash. + # If there are multiple consecutive non-letters, just replace them with a single dash. + # my-favorite-class is better than my-favorite------class + class_name = re.sub( + REGEX_CONSECUTIVE_NON_LETTERS, + "-", + class_name, + ) + + # Now we use strip to get rid of any leading or trailing dashes. + class_name = class_name.strip("-") + return class_name + + +def to_kebabcase(class_name: str) -> str: + """Convert a string to kebab-case.""" + return _to_kebabcase(class_name) diff --git a/pydis_site/apps/resources/tests/test_to_kebabcase.py b/pydis_site/apps/resources/tests/test_to_kebabcase.py new file mode 100644 index 00000000..a141143d --- /dev/null +++ b/pydis_site/apps/resources/tests/test_to_kebabcase.py @@ -0,0 +1,19 @@ +from django.test import TestCase + +from pydis_site.apps.resources.templatetags.to_kebabcase import _to_kebabcase + + +class TestToKebabcase(TestCase): + """Tests for the `as_css_class` template tag.""" + + def test_to_kebabcase(self): + """Test the to_kebabcase utility and template tag.""" + weird_input = ( + "_-_--_A_LEm0n?in&¤'the##trEE£$@€@€@@£is-NOT----QUITE//" + "as#good! as one __IN-YOUR|||HaND" + ) + + self.assertEqual( + _to_kebabcase(weird_input), + "a-lem0n-in-the-tree-is-not-quite-as-good-as-one-in-your-hand", + ) diff --git a/pydis_site/apps/resources/tests/test_views.py b/pydis_site/apps/resources/tests/test_views.py index 3ad0b958..a2a203ce 100644 --- a/pydis_site/apps/resources/tests/test_views.py +++ b/pydis_site/apps/resources/tests/test_views.py @@ -1,5 +1,4 @@ from pathlib import Path -from unittest.mock import patch from django.conf import settings from django.test import TestCase @@ -17,18 +16,14 @@ class TestResourcesView(TestCase): response = self.client.get(url) self.assertEqual(response.status_code, 200) - -class TestResourcesListView(TestCase): - @patch("pydis_site.apps.resources.views.resources_list.RESOURCES_PATH", TESTING_RESOURCES_PATH) - def test_valid_resource_list_200(self): - """Check does site return code 200 when visiting valid resource list.""" - url = reverse("resources:resources", args=("testing",)) + def test_resources_with_valid_argument(self): + """Check that you can resolve the resources when passing a valid argument.""" + url = reverse("resources:index", kwargs={"resource_type": "book"}) response = self.client.get(url) self.assertEqual(response.status_code, 200) - @patch("pydis_site.apps.resources.views.resources_list.RESOURCES_PATH", TESTING_RESOURCES_PATH) - def test_invalid_resource_list_404(self): - """Check does site return code 404 when trying to visit invalid resource list.""" - url = reverse("resources:resources", args=("invalid",)) + def test_resources_with_invalid_argument(self): + """Check that you can resolve the resources when passing an invalid argument.""" + url = reverse("resources:index", kwargs={"resource_type": "urinal-cake"}) response = self.client.get(url) self.assertEqual(response.status_code, 404) diff --git a/pydis_site/apps/resources/tests/testing_resources/testing/_category_info.yaml b/pydis_site/apps/resources/tests/testing_resources/testing/_category_info.yaml deleted file mode 100644 index bae17ea3..00000000 --- a/pydis_site/apps/resources/tests/testing_resources/testing/_category_info.yaml +++ /dev/null @@ -1 +0,0 @@ -name: Testing diff --git a/pydis_site/apps/resources/tests/testing_resources/testing/foobar/_category_info.yaml b/pydis_site/apps/resources/tests/testing_resources/testing/foobar/_category_info.yaml deleted file mode 100644 index eaac32d9..00000000 --- a/pydis_site/apps/resources/tests/testing_resources/testing/foobar/_category_info.yaml +++ /dev/null @@ -1 +0,0 @@ -name: Foobar diff --git a/pydis_site/apps/resources/tests/testing_resources/testing/foobar/resource_test.yaml b/pydis_site/apps/resources/tests/testing_resources/testing/foobar/resource_test.yaml deleted file mode 100644 index 22835090..00000000 --- a/pydis_site/apps/resources/tests/testing_resources/testing/foobar/resource_test.yaml +++ /dev/null @@ -1 +0,0 @@ -name: Resource Test diff --git a/pydis_site/apps/resources/tests/testing_resources/testing/my_resource.yaml b/pydis_site/apps/resources/tests/testing_resources/testing/my_resource.yaml deleted file mode 100644 index 61df6173..00000000 --- a/pydis_site/apps/resources/tests/testing_resources/testing/my_resource.yaml +++ /dev/null @@ -1 +0,0 @@ -name: My Resource diff --git a/pydis_site/apps/resources/urls.py b/pydis_site/apps/resources/urls.py index 10eda132..ed24dc99 100644 --- a/pydis_site/apps/resources/urls.py +++ b/pydis_site/apps/resources/urls.py @@ -1,25 +1,9 @@ -import typing -from pathlib import Path - from django_distill import distill_path from pydis_site.apps.resources import views app_name = "resources" - - -def get_all_resources() -> typing.Iterator[dict[str, str]]: - """Yield a dict of all resource categories.""" - for category in Path("pydis_site", "apps", "resources", "resources").iterdir(): - yield {"category": category.name} - - urlpatterns = [ - distill_path("", views.ResourcesView.as_view(), name="index"), - distill_path( - "<str:category>/", - views.ResourcesListView.as_view(), - name="resources", - distill_func=get_all_resources - ), + distill_path("", views.resources.ResourceView.as_view(), name="index"), + distill_path("<resource_type>/", views.resources.ResourceView.as_view(), name="index"), ] diff --git a/pydis_site/apps/resources/utils.py b/pydis_site/apps/resources/utils.py deleted file mode 100644 index 1855fc80..00000000 --- a/pydis_site/apps/resources/utils.py +++ /dev/null @@ -1,42 +0,0 @@ -import typing as t -from pathlib import Path - -import yaml - - -def get_resources(path: Path) -> t.List[t.Dict]: - """Loads resource YAMLs from provided path.""" - resources = [] - - for item in path.iterdir(): - if item.is_file() and item.suffix == ".yaml" and item.name != "_category_info.yaml": - resources.append(yaml.safe_load(item.read_text())) - - return resources - - -def get_subcategories(path: Path) -> t.List[t.Dict]: - """Loads resources subcategories with their resources by provided path.""" - subcategories = [] - - for item in path.iterdir(): - if item.is_dir() and item.joinpath("_category_info.yaml").exists(): - subcategories.append({ - "category_info": { - **yaml.safe_load( - item.joinpath("_category_info.yaml").read_text() - ), - "raw_name": item.name - }, - "resources": [ - yaml.safe_load(subitem.read_text()) - for subitem in item.iterdir() - if ( - subitem.is_file() - and subitem.suffix == ".yaml" - and subitem.name != "_category_info.yaml" - ) - ] - }) - - return subcategories diff --git a/pydis_site/apps/resources/views/__init__.py b/pydis_site/apps/resources/views/__init__.py index 8eb383b5..986f3e10 100644 --- a/pydis_site/apps/resources/views/__init__.py +++ b/pydis_site/apps/resources/views/__init__.py @@ -1,4 +1,3 @@ -from .resources import ResourcesView -from .resources_list import ResourcesListView +from .resources import ResourceView -__all__ = ["ResourcesView", "ResourcesListView"] +__all__ = ["ResourceView"] diff --git a/pydis_site/apps/resources/views/resources.py b/pydis_site/apps/resources/views/resources.py index 25ce3e50..2375f722 100644 --- a/pydis_site/apps/resources/views/resources.py +++ b/pydis_site/apps/resources/views/resources.py @@ -1,7 +1,126 @@ -from django.views.generic import TemplateView +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 -class ResourcesView(TemplateView): - """View for resources index page.""" +from pydis_site import settings +from pydis_site.apps.resources.templatetags.to_kebabcase import to_kebabcase - template_name = "resources/resources.html" +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/resources_list.py b/pydis_site/apps/resources/views/resources_list.py deleted file mode 100644 index 55f22993..00000000 --- a/pydis_site/apps/resources/views/resources_list.py +++ /dev/null @@ -1,39 +0,0 @@ -from pathlib import Path -from typing import Any, Dict - -import yaml -from django.conf import settings -from django.http import Http404 -from django.views.generic import TemplateView - -from pydis_site.apps.resources.utils import get_resources, get_subcategories - -RESOURCES_PATH = Path(settings.BASE_DIR, "pydis_site", "apps", "resources", "resources") - - -class ResourcesListView(TemplateView): - """Shows specific resources list.""" - - template_name = "resources/resources_list.html" - - def get_context_data(self, **kwargs) -> Dict[str, Any]: - """Add resources and subcategories data into context.""" - context = super().get_context_data(**kwargs) - - resource_path = RESOURCES_PATH / self.kwargs["category"] - if ( - not resource_path.is_dir() - or not resource_path.joinpath("_category_info.yaml").exists() - ): - raise Http404 - - context["resources"] = get_resources(resource_path) - context["subcategories"] = get_subcategories(resource_path) - context["category_info"] = { - **yaml.safe_load( - resource_path.joinpath("_category_info.yaml").read_text() - ), - "raw_name": resource_path.name - } - - return context 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/apps/staff/templatetags/deletedmessage_filters.py b/pydis_site/apps/staff/templatetags/deletedmessage_filters.py index 8e14ced6..5026068e 100644 --- a/pydis_site/apps/staff/templatetags/deletedmessage_filters.py +++ b/pydis_site/apps/staff/templatetags/deletedmessage_filters.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Union from django import template @@ -6,13 +7,16 @@ register = template.Library() @register.filter -def hex_colour(color: int) -> str: +def hex_colour(colour: Union[str, int]) -> str: """ - Converts an integer representation of a colour to the RGB hex value. + Converts the given representation of a colour to its RGB hex string. As we are using a Discord dark theme analogue, black colours are returned as white instead. """ - colour = f"#{color:0>6X}" + if isinstance(colour, str): + colour = colour if colour.startswith("#") else f"#{colour}" + else: + colour = f"#{colour:0>6X}" return colour if colour != "#000000" else "#FFFFFF" diff --git a/pydis_site/apps/staff/tests/test_logs_view.py b/pydis_site/apps/staff/tests/test_logs_view.py index 45e9ce8f..3e5726cd 100644 --- a/pydis_site/apps/staff/tests/test_logs_view.py +++ b/pydis_site/apps/staff/tests/test_logs_view.py @@ -95,12 +95,22 @@ class TestLogsView(TestCase): "description": "This embed is way too cool to be seen in public channels.", } + cls.embed_three = { + "description": "This embed is way too cool to be seen in public channels.", + "color": "#e74c3c", + } + + cls.embed_four = { + "description": "This embed is way too cool to be seen in public channels.", + "color": "e74c3c", + } + cls.deleted_message_two = DeletedMessage.objects.create( author=cls.author, id=614444836291870750, channel_id=1984, content='Does that mean this thing will halt?', - embeds=[cls.embed_one, cls.embed_two], + embeds=[cls.embed_one, cls.embed_two, cls.embed_three, cls.embed_four], attachments=['https://http.cat/100', 'https://http.cat/402'], deletion_context=cls.deletion_context, ) 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 d38c298b..03c16f4b 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 @@ -20,15 +21,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()], @@ -48,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', @@ -197,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 = { @@ -272,6 +297,7 @@ BULMA_SETTINGS = { "bulma-dropdown", "bulma-navbar-burger", ], + "fontawesome_token": "ff22cb6f41", } # Information about site repository @@ -287,3 +313,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") diff --git a/pydis_site/static/css/base/base.css b/pydis_site/static/css/base/base.css index f3fe1e44..4b36b7ce 100644 --- a/pydis_site/static/css/base/base.css +++ b/pydis_site/static/css/base/base.css @@ -78,12 +78,20 @@ main.site-content { color: #00000000; } +#netcup-logo { + padding-left: 15px; + background: url(https://www.netcup-wiki.de/static/assets/images/netcup_logo_white.svg) no-repeat center; + background-size: 60px; + background-position: 0px 3px; + color: #00000000; +} + #django-logo { padding-bottom: 2px; - background: url(https://static.djangoproject.com/img/logos/django-logo-negative.png) no-repeat center; - filter: grayscale(1) invert(0.02); + background: url(https://static.djangoproject.com/img/logos/django-logo-negative.svg) no-repeat center; + filter: grayscale(1) invert(0.09); background-size: 52px 25.5px; - background-position: -1px -2px; + background-position: -2px -1px; color: #00000000; } @@ -92,6 +100,7 @@ main.site-content { height: 20px; background: url(https://bulma.io/images/bulma-logo-white.png) no-repeat center; background-size: 60px; + background-position: 0px 3px; color: #00000000; } diff --git a/pydis_site/static/css/collapsibles.css b/pydis_site/static/css/collapsibles.css new file mode 100644 index 00000000..1d73fa00 --- /dev/null +++ b/pydis_site/static/css/collapsibles.css @@ -0,0 +1,11 @@ +.collapsible { + cursor: pointer; + width: 100%; + border: none; + outline: none; +} + +.collapsible-content { + transition: max-height 0.3s ease-out; + overflow: hidden; +} diff --git a/pydis_site/static/css/content/page.css b/pydis_site/static/css/content/page.css index 2d4bd325..d831f86d 100644 --- a/pydis_site/static/css/content/page.css +++ b/pydis_site/static/css/content/page.css @@ -77,16 +77,3 @@ ul.menu-list.toc { li img { margin-top: 0.5em; } - -.collapsible { - cursor: pointer; - width: 100%; - border: none; - outline: none; -} - -.collapsible-content { - overflow: hidden; - max-height: 0; - transition: max-height 0.2s ease-out; -} diff --git a/pydis_site/static/css/events/base.css b/pydis_site/static/css/events/base.css index 266bca1d..9e244ed9 100644 --- a/pydis_site/static/css/events/base.css +++ b/pydis_site/static/css/events/base.css @@ -10,3 +10,11 @@ pre { */ background-color: #282c34; } + +.panel .panel-heading { + /* + * Remove whitespace between the panel heading and the first item in a panel, + * since it makes the first panel item taller than the others. + */ + margin-bottom: 0 !important +} diff --git a/pydis_site/static/css/home/index.css b/pydis_site/static/css/home/index.css index 7ec8af74..e117a35b 100644 --- a/pydis_site/static/css/home/index.css +++ b/pydis_site/static/css/home/index.css @@ -49,11 +49,16 @@ h1 { margin: auto auto; } -#wave-hero-right img{ +#wave-hero-right img { border-radius: 10px; box-shadow: 0 1px 6px rgba(0,0,0,0.16), 0 1px 6px rgba(0,0,0,0.23); margin-top: 1em; text-align: right; + transition: all 0.3s cubic-bezier(.25,.8,.25,1); +} + +#wave-hero-right img:hover { + box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22); } #wave-hero .wave { @@ -121,8 +126,7 @@ h1 { margin: 0 4% 0 4%; background-color: #3EB2EF; color: white; - font-size: 15px; - line-height: 33px; + line-height: 31px; border:none; box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); transition: all 0.3s cubic-bezier(.25,.8,.25,1); diff --git a/pydis_site/static/css/resources/resources.css b/pydis_site/static/css/resources/resources.css index cf4cb472..96d06111 100644 --- a/pydis_site/static/css/resources/resources.css +++ b/pydis_site/static/css/resources/resources.css @@ -1,29 +1,293 @@ -.box, .tile.is-parent { - transition: 0.1s ease-out; +/* Colors for icons */ +i.resource-icon.is-orangered { + color: #FE640A; } -.box { - min-height: 15vh; +i.resource-icon.is-blurple { + color: #7289DA; } -.tile.is-parent:hover .box { - box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); +i.resource-icon.is-teal { + color: #95DBE5; } -.tile.is-parent:hover { - padding: 0.65rem 0.85rem 0.85rem 0.65rem; - filter: saturate(1.1) brightness(1.1); +i.resource-icon.is-youtube-red { + color: #BB0000; +} +i.resource-icon.is-black { + color: #2c3334; +} + +/* Colors when icons are hovered */ +i.resource-icon.is-hoverable:hover { + filter: brightness(125%); +} +i.resource-icon.is-hoverable.is-black:hover { + filter: brightness(170%); +} +i.resource-icon.is-hoverable.is-teal:hover { + filter: brightness(80%); +} + +/* Icon padding */ +.breadcrumb-section { + padding: 1rem; +} +i.has-icon-padding { + padding: 0 10px 25px 0; +} +#tab-content p { + display: none; +} +#tab-content p.is-active { +display: block; +} + +/* Disable highlighting for all text in the filters. */ +.filter-checkbox, +.filter-panel label, +.card-header span { + user-select: none +} + +/* Remove pointless margin in panel header */ +#filter-panel-header { + margin-bottom: 0; +} + +/* Full width filter cards */ +#resource-filtering-panel .card .collapsible-content .card-content { + padding:0 +} + +/* Don't round the corners of the collapsibles */ +.filter-category-header { + border-radius: 0; +} + +/* Make the checkboxes indent under the filter headers */ +.filter-category-header .card-header .card-header-title { + padding-left: 0; +} +.filter-panel { + padding-left: 1.5rem; +} +.filter-checkbox { + margin-right: 0.25em !important; +} + +/* Style the search bar */ +#resource-search { + margin: 0.25em 0.25em 0 0.25em; +} + +/* Center the 404 div */ +.no-resources-found { + display: none; + flex-direction: column; + align-items: center; + margin-top: 1em; +} + +/* Make sure jQuery will use flex when setting `show()` again. */ +.no-resources-found[style*='display: block'] { + display: flex !important; +} + +/* By default, we hide the search tag. We'll add it only when there's a search happening. */ +.tag.search-query { + display: none; + min-width: fit-content; + max-width: fit-content; + padding-right: 2em; +} +.tag.search-query .inner { + display: inline-block; + padding: 0; + max-width: 16.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 2em; +} +.tag.search-query i { + margin: 0 !important; + display: inline-block; + line-height: 2em; + float: left; + padding-right: 1em; +} + +/* Don't allow the tag pool to exceed its parent containers width. */ +#tag-pool { + max-width: 100%; +} + +/* Disable clicking on the checkbox itself. */ +/* Instead, we want to let the anchor tag handle clicks. */ +.filter-checkbox { + pointer-events: none; +} + +/* Blurple category icons */ +i.is-primary { + color: #7289da; } -#readingBlock { - background-image: linear-gradient(141deg, #911eb4 0%, #b631de 71%, #cf4bf7 100%); +/* A little space above the filter card, please! */ +.filter-tags { + padding-bottom: .5em; } -#interactiveBlock { - background-image: linear-gradient(141deg, #d05600 0%, #da722a 71%, #e68846 100%); +/* Style the close all filters button */ +.close-filters-button { + margin-left: auto; + display:none; +} +.close-filters-button a { + height: fit-content; + width: fit-content; + margin-right: 6px; +} +.close-filters-button a i { + color: #939bb3; +} +.close-filters-button a i:hover { + filter: brightness(115%); +} + +/* When hovering title anchors, just make the color a lighter primary, not black. */ +.resource-box a:hover { + filter: brightness(120%); + color: #7289DA; +} + +/* Set default display to inline-flex, for centering. */ +span.filter-box-tag { + display: none; + align-items: center; + cursor: pointer; + user-select: none; +} + +/* Make sure jQuery will use inline-flex when setting `show()` again. */ +span.filter-box-tag[style*='display: block'] { + display: inline-flex !important; +} + +/* Make resource tags clickable */ +.resource-tag { + cursor: pointer; + user-select: none; +} + +/* Give the resource tags a bit of breathing room */ +.resource-tag-container { + padding-left: 1.5rem; +} + +/* When hovering tags, brighten them a bit. */ +.resource-tag:hover, +.filter-box-tag:hover { + filter: brightness(95%); +} + +/* Move the x down 1 pixel to align center */ +button.delete { + margin-top: 1px; +} + +/* Colors for delete button x's */ +button.delete.is-primary::before, +button.delete.is-primary::after { + background-color: #2a45a2; +} +button.delete.is-success::before, +button.delete.is-success::after { + background-color: #2c9659; +} +button.delete.is-danger::before, +button.delete.is-danger::after { + background-color: #c32841; +} +button.delete.is-info::before, +button.delete.is-info::after { + background-color: #237fbd; +} + +/* Give outlines to active tags */ +span.filter-box-tag, +span.resource-tag.active, +.tag.search-query { + outline-width: 1px; + outline-style: solid; +} + +/* Disable transitions */ +.no-transition { + -webkit-transition: none !important; + -moz-transition: none !important; + -o-transition: none !important; + transition: none !important; +} + +/* Make filter tags sparkle when selected! */ +@keyframes glow_success { + from { box-shadow: 0 0 2px 2px #aef4af; } + 33% { box-shadow: 0 0 2px 2px #87af7a; } + 66% { box-shadow: 0 0 2px 2px #9ceaac; } + to { box-shadow: 0 0 2px 2px #7cbf64; } +} + +@keyframes glow_primary { + from { box-shadow: 0 0 2px 2px #aeb8f3; } + 33% { box-shadow: 0 0 2px 2px #909ed9; } + 66% { box-shadow: 0 0 2px 2px #6d7ed4; } + to { box-shadow: 0 0 2px 2px #6383b3; } +} + +@keyframes glow_danger { + from { box-shadow: 0 0 2px 2px #c9495f; } + 33% { box-shadow: 0 0 2px 2px #92486f; } + 66% { box-shadow: 0 0 2px 2px #d455ba; } + to { box-shadow: 0 0 2px 2px #ff8192; } +} +@keyframes glow_info { + from { box-shadow: 0 0 2px 2px #4592c9; } + 33% { box-shadow: 0 0 2px 2px #6196bb; } + 66% { box-shadow: 0 0 2px 2px #5adade; } + to { box-shadow: 0 0 2px 2px #6bcfdc; } +} + +span.resource-tag.active.is-primary { + animation: glow_primary 4s infinite alternate; +} +span.resource-tag.active.has-background-danger-light { + animation: glow_danger 4s infinite alternate; +} +span.resource-tag.active.has-background-success-light { + animation: glow_success 4s infinite alternate; +} +span.resource-tag.active.has-background-info-light { + animation: glow_info 4s infinite alternate; } -#communitiesBlock { - background-image: linear-gradient(141deg, #3b756f 0%, #3a847c 71%, #41948b 100%); +/* Smaller filter category headers when on mobile */ +@media screen and (max-width: 480px) { + .filter-category-header .card-header .card-header-title { + font-size: 14px; + padding: 0; + } + .filter-panel { + padding-top: 4px; + padding-bottom: 4px; + } + .tag.search-query .inner { + max-width: 16.2rem; + } } -#podcastsBlock { - background-image: linear-gradient(141deg, #232382 0%, #30309c 71%, #4343ad 100%); +/* Constrain the width of the filterbox */ +@media screen and (min-width: 769px) { + .filtering-column { + max-width: 25rem; + min-width: 18rem; + } } diff --git a/pydis_site/static/css/resources/resources_list.css b/pydis_site/static/css/resources/resources_list.css deleted file mode 100644 index 33129c87..00000000 --- a/pydis_site/static/css/resources/resources_list.css +++ /dev/null @@ -1,55 +0,0 @@ -.breadcrumb-section { - padding: 1rem; -} - -i.resource-icon.is-orangered { - color: #FE640A; -} - -i.resource-icon.is-orangered:hover { - color: #fe9840; -} - -i.resource-icon.is-blurple { - color: #7289DA; -} - -i.resource-icon.is-blurple:hover { - color: #93a8da; -} - -i.resource-icon.is-teal { - color: #95DBE5; -} - -i.resource-icon.is-teal:hover { - color: #a9f5ff; -} - -i.resource-icon.is-youtube-red { - color: #BB0000; -} - -i.resource-icon.is-youtube-red:hover { - color: #f80000; -} - -i.resource-icon.is-amazon-orange { - color: #FF9900; -} - -i.resource-icon.is-amazon-orange:hover { - color: #ffb71a; -} - -i.resource-icon.is-black { - color: #000000; -} - -i.resource-icon.is-black { - color: #191919; -} - -i.has-icon-padding { - padding: 0 10px 25px 0; -} diff --git a/pydis_site/static/css/staff/logs.css b/pydis_site/static/css/staff/logs.css index acf4f1f7..56a12380 100644 --- a/pydis_site/static/css/staff/logs.css +++ b/pydis_site/static/css/staff/logs.css @@ -25,7 +25,10 @@ main.site-content { .discord-message:first-child { border-top: 1px; +} +.discord-message-content { + overflow-wrap: break-word; } .discord-message-header { diff --git a/pydis_site/static/images/content/contributing/pull_request.png b/pydis_site/static/images/content/contributing/pull_request.png Binary files differnew file mode 100644 index 00000000..87b7ffbe --- /dev/null +++ b/pydis_site/static/images/content/contributing/pull_request.png 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 Binary files differnew file mode 100644 index 00000000..d7176393 --- /dev/null +++ b/pydis_site/static/images/content/discord_colored_messages/ansi-colors.png diff --git a/pydis_site/static/images/content/discord_colored_messages/result.png b/pydis_site/static/images/content/discord_colored_messages/result.png Binary files differnew file mode 100644 index 00000000..a666804e --- /dev/null +++ b/pydis_site/static/images/content/discord_colored_messages/result.png diff --git a/pydis_site/static/images/content/help_channels/available_message.png b/pydis_site/static/images/content/help_channels/available_message.png Binary files differindex 05f6ec7d..09668c9b 100644 --- a/pydis_site/static/images/content/help_channels/available_message.png +++ b/pydis_site/static/images/content/help_channels/available_message.png diff --git a/pydis_site/static/images/content/help_channels/claimed_channel.png b/pydis_site/static/images/content/help_channels/claimed_channel.png Binary files differnew file mode 100644 index 00000000..777e31ea --- /dev/null +++ b/pydis_site/static/images/content/help_channels/claimed_channel.png diff --git a/pydis_site/static/images/content/help_channels/dormant_channels.png b/pydis_site/static/images/content/help_channels/dormant_channels.png Binary files differindex 2c53de87..7c9ba61e 100644 --- a/pydis_site/static/images/content/help_channels/dormant_channels.png +++ b/pydis_site/static/images/content/help_channels/dormant_channels.png diff --git a/pydis_site/static/images/content/help_channels/topical_channels.png b/pydis_site/static/images/content/help_channels/topical_channels.png Binary files differindex 63b48e7b..43530cbe 100644 --- a/pydis_site/static/images/content/help_channels/topical_channels.png +++ b/pydis_site/static/images/content/help_channels/topical_channels.png diff --git a/pydis_site/static/images/events/Replit.png b/pydis_site/static/images/events/Replit.png Binary files differnew file mode 100644 index 00000000..a8202641 --- /dev/null +++ b/pydis_site/static/images/events/Replit.png diff --git a/pydis_site/static/images/events/summer_code_jam_2022/front_page_banners/live_now.png b/pydis_site/static/images/events/summer_code_jam_2022/front_page_banners/live_now.png Binary files differnew file mode 100644 index 00000000..eb30bf7e --- /dev/null +++ b/pydis_site/static/images/events/summer_code_jam_2022/front_page_banners/live_now.png diff --git a/pydis_site/static/images/events/summer_code_jam_2022/front_page_banners/qualifier_release.png b/pydis_site/static/images/events/summer_code_jam_2022/front_page_banners/qualifier_release.png Binary files differnew file mode 100644 index 00000000..1e45024b --- /dev/null +++ b/pydis_site/static/images/events/summer_code_jam_2022/front_page_banners/qualifier_release.png diff --git a/pydis_site/static/images/events/summer_code_jam_2022/front_page_banners/sign_up.png b/pydis_site/static/images/events/summer_code_jam_2022/front_page_banners/sign_up.png Binary files differnew file mode 100644 index 00000000..f807418e --- /dev/null +++ b/pydis_site/static/images/events/summer_code_jam_2022/front_page_banners/sign_up.png diff --git a/pydis_site/static/images/events/summer_code_jam_2022/site_banner.png b/pydis_site/static/images/events/summer_code_jam_2022/site_banner.png Binary files differnew file mode 100644 index 00000000..30b3dfbc --- /dev/null +++ b/pydis_site/static/images/events/summer_code_jam_2022/site_banner.png diff --git a/pydis_site/static/images/navbar/discord.svg b/pydis_site/static/images/navbar/discord.svg index 406e3836..2cf3d6cc 100644 --- a/pydis_site/static/images/navbar/discord.svg +++ b/pydis_site/static/images/navbar/discord.svg @@ -1,165 +1,244 @@ <?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="120mm" height="30mm" viewBox="0 0 120 30" version="1.1" id="svg8" - inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" - sodipodi:docname="discord.svg"> - <defs - id="defs2"> - <rect + inkscape:version="1.2 (dc2aedaf03, 2022-05-15)" + sodipodi:docname="discord.svg" + xml:space="preserve" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"><defs + id="defs2"><rect x="75.819944" y="98.265513" width="25.123336" height="7.8844509" - id="rect953" /> - <rect + id="rect953" /><rect x="75.819946" y="98.265511" width="25.123337" height="7.8844509" - id="rect953-0" /> - <rect + id="rect953-0" /><rect x="75.819946" y="98.265511" width="25.123337" height="7.8844509" - id="rect968" /> - </defs> - <sodipodi:namedview + id="rect968" /><clipPath + id="clip0"><rect + width="71" + height="55" + fill="white" + id="rect716" /></clipPath><clipPath + id="clip0-9"><rect + width="71" + height="55" + fill="white" + id="rect852" /></clipPath><clipPath + id="clip0-6"><rect + width="292" + height="56.4706" + fill="white" + transform="translate(0 11.7646)" + id="rect159" /></clipPath><clipPath + id="clip1"><rect + width="292" + height="56.4706" + fill="white" + transform="translate(0 11.7646)" + id="rect162" /></clipPath><clipPath + id="clip0-65"><rect + width="292" + height="56.4706" + fill="white" + transform="translate(0 11.7646)" + id="rect338" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath405"><rect + width="292" + height="56.470596" + fill="#ffffff" + id="rect407" + x="-8.4424901e-06" + y="11.764597" + style="stroke-width:1" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath409"><rect + width="292" + height="56.470596" + fill="#ffffff" + id="rect411" + x="-8.4424901e-06" + y="11.764597" + style="stroke-width:1" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath413"><rect + width="292" + height="56.470596" + fill="#ffffff" + id="rect415" + x="-8.4424901e-06" + y="11.764597" + style="stroke-width:1" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath417"><rect + width="292" + height="56.470596" + fill="#ffffff" + id="rect419" + x="-8.4424901e-06" + y="11.764597" + style="stroke-width:1" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath421"><rect + width="292" + height="56.470596" + fill="#ffffff" + id="rect423" + x="-8.4424901e-06" + y="11.764597" + style="stroke-width:1" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath425"><rect + width="292" + height="56.470596" + fill="#ffffff" + id="rect427" + x="-8.4424901e-06" + y="11.764597" + style="stroke-width:1" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath429"><rect + width="292" + height="56.470596" + fill="#ffffff" + id="rect431" + x="-8.4424901e-06" + y="11.764597" + style="stroke-width:1" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath433"><rect + width="292" + height="56.470596" + fill="#ffffff" + id="rect435" + x="-8.4424901e-06" + y="11.764597" + style="stroke-width:1" /></clipPath><clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath437"><rect + width="292" + height="56.470596" + fill="#ffffff" + id="rect439" + x="-8.4424901e-06" + y="11.764597" + style="stroke-width:1" /></clipPath></defs><sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" - inkscape:pageopacity="0.0" + inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:zoom="2.8" - inkscape:cx="194.44623" - inkscape:cy="53.152927" + inkscape:cx="226.07143" + inkscape:cy="53.035714" inkscape:document-units="mm" inkscape:current-layer="layer1" showgrid="false" - inkscape:window-width="2560" - inkscape:window-height="1413" - inkscape:window-x="4880" - inkscape:window-y="677" + inkscape:window-width="1920" + inkscape:window-height="1001" + inkscape:window-x="-9" + inkscape:window-y="-9" inkscape:window-maximized="1" fit-margin-top="0" fit-margin-left="0" fit-margin-right="0" fit-margin-bottom="0" - inkscape:document-rotation="0" /> - <metadata - id="metadata5"> - <rdf:RDF> - <cc:Work - rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - <dc:title /> - </cc:Work> - </rdf:RDF> - </metadata> - <g + inkscape:document-rotation="0" + inkscape:pagecheckerboard="false" + inkscape:showpageshadow="2" + inkscape:deskcolor="#d1d1d1" /><metadata + id="metadata5"><rdf:RDF><cc:Work + rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" - transform="translate(-52.233408,-75.88169)"> - <rect + transform="translate(-52.233408,-75.88169)"><rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.137677;paint-order:stroke fill markers;stop-color:#000000" id="rect832" width="61.511906" height="30" x="52.23341" - y="75.881691" /> - <g - id="g910" - transform="matrix(0.90000009,0,0,0.90000009,17.445516,9.7980333)"> - <g - id="g850" - transform="matrix(0.06491223,0,0,0.06491223,109.76284,82.07218)"> - <path - class="st0" - d="m 142.8,120.1 c -5.7,0 -10.2,4.9 -10.2,11 0,6.1 4.6,11 10.2,11 5.7,0 10.2,-4.9 10.2,-11 0,-6.1 -4.6,-11 -10.2,-11 z m -36.5,0 c -5.7,0 -10.2,4.9 -10.2,11 0,6.1 4.6,11 10.2,11 5.7,0 10.2,-4.9 10.2,-11 0.1,-6.1 -4.5,-11 -10.2,-11 z" - id="path836" /> - <path - class="st0" - d="m 191.4,36.9 h -134 c -11.3,0 -20.5,9.2 -20.5,20.5 v 134 c 0,11.3 9.2,20.5 20.5,20.5 h 113.4 l -5.3,-18.3 12.8,11.8 12.1,11.1 21.6,18.7 V 57.4 C 211.9,46.1 202.7,36.9 191.4,36.9 Z m -38.6,129.5 c 0,0 -3.6,-4.3 -6.6,-8 13.1,-3.7 18.1,-11.8 18.1,-11.8 -4.1,2.7 -8,4.6 -11.5,5.9 -5,2.1 -9.8,3.4 -14.5,4.3 -9.6,1.8 -18.4,1.3 -25.9,-0.1 -5.7,-1.1 -10.6,-2.6 -14.7,-4.3 -2.3,-0.9 -4.8,-2 -7.3,-3.4 -0.3,-0.2 -0.6,-0.3 -0.9,-0.5 -0.2,-0.1 -0.3,-0.2 -0.4,-0.2 -1.8,-1 -2.8,-1.7 -2.8,-1.7 0,0 4.8,7.9 17.5,11.7 -3,3.8 -6.7,8.2 -6.7,8.2 C 75,165.8 66.6,151.4 66.6,151.4 66.6,119.5 81,93.6 81,93.6 95.4,82.9 109,83.2 109,83.2 l 1,1.2 c -18,5.1 -26.2,13 -26.2,13 0,0 2.2,-1.2 5.9,-2.8 10.7,-4.7 19.2,-5.9 22.7,-6.3 0.6,-0.1 1.1,-0.2 1.7,-0.2 6.1,-0.8 13,-1 20.2,-0.2 9.5,1.1 19.7,3.9 30.1,9.5 0,0 -7.9,-7.5 -24.9,-12.6 l 1.4,-1.6 c 0,0 13.7,-0.3 28,10.4 0,0 14.4,25.9 14.4,57.8 0,-0.1 -8.4,14.3 -30.5,15 z m 151,-86.7 H 270.6 V 117 l 22.1,19.9 v -36.2 h 11.8 c 7.5,0 11.2,3.6 11.2,9.4 v 27.7 c 0,5.8 -3.5,9.7 -11.2,9.7 h -34 v 21.1 h 33.2 c 17.8,0.1 34.5,-8.8 34.5,-29.2 V 109.6 C 338.3,88.8 321.6,79.7 303.8,79.7 Z m 174,59.7 v -30.6 c 0,-11 19.8,-13.5 25.8,-2.5 l 18.3,-7.4 c -7.2,-15.8 -20.3,-20.4 -31.2,-20.4 -17.8,0 -35.4,10.3 -35.4,30.3 v 30.6 c 0,20.2 17.6,30.3 35,30.3 11.2,0 24.6,-5.5 32,-19.9 l -19.6,-9 c -4.8,12.3 -24.9,9.3 -24.9,-1.4 z M 417.3,113 c -6.9,-1.5 -11.5,-4 -11.8,-8.3 0.4,-10.3 16.3,-10.7 25.6,-0.8 l 14.7,-11.3 c -9.2,-11.2 -19.6,-14.2 -30.3,-14.2 -16.3,0 -32.1,9.2 -32.1,26.6 0,16.9 13,26 27.3,28.2 7.3,1 15.4,3.9 15.2,8.9 -0.6,9.5 -20.2,9 -29.1,-1.8 l -14.2,13.3 c 8.3,10.7 19.6,16.1 30.2,16.1 16.3,0 34.4,-9.4 35.1,-26.6 1,-21.7 -14.8,-27.2 -30.6,-30.1 z m -67,55.5 h 22.4 V 79.7 H 350.3 Z M 728,79.7 H 694.8 V 117 l 22.1,19.9 v -36.2 h 11.8 c 7.5,0 11.2,3.6 11.2,9.4 v 27.7 c 0,5.8 -3.5,9.7 -11.2,9.7 h -34 v 21.1 H 728 c 17.8,0.1 34.5,-8.8 34.5,-29.2 V 109.6 C 762.5,88.8 745.8,79.7 728,79.7 Z M 565.1,78.5 c -18.4,0 -36.7,10 -36.7,30.5 v 30.3 c 0,20.3 18.4,30.5 36.9,30.5 18.4,0 36.7,-10.2 36.7,-30.5 V 109 C 602,88.6 583.5,78.5 565.1,78.5 Z m 14.4,60.8 c 0,6.4 -7.2,9.7 -14.3,9.7 -7.2,0 -14.4,-3.1 -14.4,-9.7 V 109 c 0,-6.5 7,-10 14,-10 7.3,0 14.7,3.1 14.7,10 z M 682.4,109 c -0.5,-20.8 -14.7,-29.2 -33,-29.2 h -35.5 v 88.8 h 22.7 v -28.2 h 4 l 20.6,28.2 h 28 L 665,138.1 c 10.7,-3.4 17.4,-12.7 17.4,-29.1 z m -32.6,12 h -13.2 v -20.3 h 13.2 c 14.1,0 14.1,20.3 0,20.3 z" - id="path838" /> - </g> - <path - id="path4789-6" - class="" - d="m 167.72059,90.383029 -3.19204,3.19205 c -0.15408,0.15408 -0.40352,0.15408 -0.55746,0 l -0.37229,-0.37231 c -0.15368,-0.15369 -0.15408,-0.40277 -4.9e-4,-0.55681 l 2.52975,-2.54167 -2.52975,-2.54164 c -0.15329,-0.15408 -0.15309,-0.40312 4.9e-4,-0.55681 l 0.37229,-0.37228 c 0.15408,-0.15408 0.40353,-0.15408 0.55746,0 l 3.19204,3.19201 c 0.15408,0.15407 0.15408,0.40354 0,0.55746 z" - inkscape:connector-curvature="0" - style="fill:#ffffff;fill-opacity:1;stroke-width:0.0164247" /> - </g> - <g - id="g904" - transform="matrix(0.90000009,0,0,0.90000009,10.464254,9.7980333)"> - <g - id="g850-3" - transform="matrix(0.06491223,0,0,0.06491223,52.083661,82.07218)"> - <path - class="st0" - d="m 142.8,120.1 c -5.7,0 -10.2,4.9 -10.2,11 0,6.1 4.6,11 10.2,11 5.7,0 10.2,-4.9 10.2,-11 0,-6.1 -4.6,-11 -10.2,-11 z m -36.5,0 c -5.7,0 -10.2,4.9 -10.2,11 0,6.1 4.6,11 10.2,11 5.7,0 10.2,-4.9 10.2,-11 0.1,-6.1 -4.5,-11 -10.2,-11 z" - id="path836-5" - style="fill:#7289da;fill-opacity:1" /> - <path - class="st0" - d="m 191.4,36.9 h -134 c -11.3,0 -20.5,9.2 -20.5,20.5 v 134 c 0,11.3 9.2,20.5 20.5,20.5 h 113.4 l -5.3,-18.3 12.8,11.8 12.1,11.1 21.6,18.7 V 57.4 C 211.9,46.1 202.7,36.9 191.4,36.9 Z m -38.6,129.5 c 0,0 -3.6,-4.3 -6.6,-8 13.1,-3.7 18.1,-11.8 18.1,-11.8 -4.1,2.7 -8,4.6 -11.5,5.9 -5,2.1 -9.8,3.4 -14.5,4.3 -9.6,1.8 -18.4,1.3 -25.9,-0.1 -5.7,-1.1 -10.6,-2.6 -14.7,-4.3 -2.3,-0.9 -4.8,-2 -7.3,-3.4 -0.3,-0.2 -0.6,-0.3 -0.9,-0.5 -0.2,-0.1 -0.3,-0.2 -0.4,-0.2 -1.8,-1 -2.8,-1.7 -2.8,-1.7 0,0 4.8,7.9 17.5,11.7 -3,3.8 -6.7,8.2 -6.7,8.2 C 75,165.8 66.6,151.4 66.6,151.4 66.6,119.5 81,93.6 81,93.6 95.4,82.9 109,83.2 109,83.2 l 1,1.2 c -18,5.1 -26.2,13 -26.2,13 0,0 2.2,-1.2 5.9,-2.8 10.7,-4.7 19.2,-5.9 22.7,-6.3 0.6,-0.1 1.1,-0.2 1.7,-0.2 6.1,-0.8 13,-1 20.2,-0.2 9.5,1.1 19.7,3.9 30.1,9.5 0,0 -7.9,-7.5 -24.9,-12.6 l 1.4,-1.6 c 0,0 13.7,-0.3 28,10.4 0,0 14.4,25.9 14.4,57.8 0,-0.1 -8.4,14.3 -30.5,15 z" - id="path838-6" - style="fill:#7289da;fill-opacity:1" - sodipodi:nodetypes="sssssccccccscccccccccccccccccccccccccccc" /> - </g> - <path - id="path4789-6-2" - class="" - d="m 107.16039,90.382629 -3.19204,3.19205 c -0.15408,0.15408 -0.40352,0.15408 -0.55746,0 l -0.37229,-0.37231 c -0.15368,-0.15369 -0.15408,-0.40277 -5.3e-4,-0.55681 l 2.52975,-2.54167 -2.52975,-2.54164 c -0.15329,-0.15408 -0.15309,-0.40312 5.3e-4,-0.55681 l 0.37229,-0.37228 c 0.15408,-0.15408 0.40353,-0.15408 0.55746,0 l 3.19204,3.19201 c 0.15408,0.15407 0.15408,0.40354 0,0.55746 z" - inkscape:connector-curvature="0" - style="fill:#7289da;fill-opacity:1;stroke-width:0.0164247" /> - <g - aria-label="JOIN US" - transform="matrix(1.2501707,0,0,1.2501707,-25.160061,-36.966352)" - id="text951" - style="font-style:normal;font-weight:normal;font-size:6.35px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect953-0);fill:#7289da;fill-opacity:1;stroke:none"> - <path - d="m 75.839362,102.56309 c 0.127,0.9525 0.89535,1.3843 1.67005,1.3843 0.85725,0 1.7145,-0.55245 1.7145,-1.53035 v -3.028953 h -2.1463 v 1.028703 h 1.02235 v 2.00025 c 0,0.26035 -0.2667,0.4318 -0.5461,0.4318 -0.2794,0 -0.57785,-0.14605 -0.64135,-0.508 z" - style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1" - id="path850" /> - <path - d="m 79.795412,102.40434 c 0,1.0287 0.93345,1.54305 1.8669,1.54305 0.93345,0 1.86055,-0.51435 1.86055,-1.54305 v -1.5367 c 0,-1.028703 -0.93345,-1.543053 -1.8669,-1.543053 -0.93345,0 -1.86055,0.508 -1.86055,1.543053 z m 1.13665,-1.5367 c 0,-0.3302 0.3556,-0.508 0.7112,-0.508 0.3683,0 0.74295,0.15875 0.74295,0.508 v 1.5367 c 0,0.32385 -0.36195,0.48895 -0.7239,0.48895 -0.36195,0 -0.73025,-0.15875 -0.73025,-0.48895 z" - style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1" - id="path852" /> - <path - d="m 85.262755,99.388087 h -1.13665 v 4.495803 h 1.13665 z" - style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1" - id="path854" /> - <path - d="m 85.973945,103.88389 h 1.13665 v -1.79705 l -0.14605,-0.86995 0.03175,-0.006 0.3937,0.9017 1.016,1.77165 h 1.14935 v -4.495803 h -1.1303 v 2.038353 c 0.0063,0 0.12065,0.7747 0.127,0.7747 l -0.03175,0.006 -0.381,-0.9017 -1.08585,-1.917703 h -1.0795 z" - style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1" - id="path856" /> - <path - d="m 92.546182,99.388087 h -1.14935 v 2.990853 c -0.0063,2.1082 3.5814,2.1082 3.58775,0 v -2.990853 h -1.14935 v 2.990853 c -0.0064,0.7239 -1.28905,0.7239 -1.28905,0 z" - style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1" - id="path858" /> - <path - d="m 95.44178,103.13459 c 0.4191,0.53975 0.9906,0.8128 1.53035,0.8128 0.8255,0 1.7399,-0.47625 1.778,-1.3462 0.0508,-1.1049 -0.7493,-1.3843 -1.5494,-1.53035 -0.34925,-0.0762 -0.5842,-0.2032 -0.5969,-0.4191 0.01905,-0.5207 0.8255,-0.53975 1.2954,-0.0381 l 0.74295,-0.5715 c -0.46355,-0.565153 -0.9906,-0.717553 -1.5367,-0.717553 -0.8255,0 -1.6256,0.46355 -1.6256,1.346203 0,0.85725 0.6604,1.31445 1.3843,1.42875 0.3683,0.0508 0.78105,0.19685 0.76835,0.45085 -0.03175,0.4826 -1.02235,0.4572 -1.4732,-0.0889 z" - style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1" - id="path860" /> - </g> - </g> - </g> - <style - id="style834">.st0{fill:#FFFFFF;}</style> -</svg> + y="75.881691" /><path + id="path4789-6" + class="" + d="m 168.39406,91.142768 -2.87283,2.872845 c -0.13868,0.138672 -0.36317,0.138672 -0.50172,0 l -0.33506,-0.335079 c -0.13831,-0.138321 -0.13867,-0.362493 -4.4e-4,-0.501129 l 2.27677,-2.287503 -2.27677,-2.287477 c -0.13796,-0.138672 -0.13778,-0.362808 4.4e-4,-0.501129 l 0.33506,-0.335052 c 0.13867,-0.138672 0.36318,-0.138672 0.50172,0 l 2.87283,2.872809 c 0.13867,0.138663 0.13867,0.363187 0,0.501715 z" + inkscape:connector-curvature="0" + style="fill:#ffffff;fill-opacity:1;stroke-width:0.0147822" /><path + id="path4789-6-2" + class="" + d="m 106.90861,91.142408 -2.87283,2.872845 c -0.13867,0.138672 -0.36317,0.138672 -0.50172,0 L 103.199,93.680174 c -0.13831,-0.138321 -0.13867,-0.362493 -4.7e-4,-0.501129 l 2.27677,-2.287503 -2.27677,-2.287477 c -0.13796,-0.138672 -0.13778,-0.362808 4.7e-4,-0.501129 l 0.33506,-0.335052 c 0.13868,-0.138672 0.36318,-0.138672 0.50172,0 l 2.87283,2.872809 c 0.13868,0.138663 0.13868,0.363187 0,0.501715 z" + inkscape:connector-curvature="0" + style="fill:#7289da;fill-opacity:1;stroke-width:0.0147822" /><g + style="fill:none" + id="g196" + transform="matrix(0.14732984,0,0,0.14732984,118.63341,84.998511)"><g + clip-path="url(#clip1)" + id="g155"><path + d="m 61.7958,16.494 c -4.7222,-2.2094 -9.7714,-3.8151 -15.0502,-4.7294 -0.6483,1.1721 -1.4057,2.7486 -1.9279,4.0027 -5.6115,-0.8439 -11.1714,-0.8439 -16.6797,0 -0.5221,-1.2541 -1.2967,-2.8306 -1.9508,-4.0027 -5.2845,0.9143 -10.3395,2.5259 -15.0617,4.7411 C 1.60078,30.8988 -0.981215,44.9344 0.309785,58.7707 6.62708,63.4883 12.7493,66.3541 18.7682,68.2294 c 1.4861,-2.0453 2.8115,-4.2195 3.9533,-6.5109 -2.1746,-0.8263 -4.2574,-1.846 -6.2254,-3.0298 0.5221,-0.3868 1.0328,-0.7912 1.5262,-1.2073 12.0034,5.6143 25.0454,5.6143 36.9054,0 0.4992,0.4161 1.0098,0.8205 1.5262,1.2073 -1.9738,1.1896 -4.0623,2.2093 -6.2369,3.0357 1.1418,2.2855 2.4615,4.4656 3.9533,6.5108 6.0247,-1.8753 12.1526,-4.741 18.4699,-9.4645 C 74.155,42.7309 70.0525,28.8242 61.7958,16.494 Z m -37.439,33.7675 c -3.6033,0 -6.5583,-3.3639 -6.5583,-7.4603 0,-4.0964 2.8919,-7.4661 6.5583,-7.4661 3.6665,0 6.6214,3.3638 6.5583,7.4661 0.0057,4.0964 -2.8918,7.4603 -6.5583,7.4603 z m 24.2364,0 c -3.6033,0 -6.5583,-3.3639 -6.5583,-7.4603 0,-4.0964 2.8918,-7.4661 6.5583,-7.4661 3.6664,0 6.6214,3.3638 6.5583,7.4661 0,4.0964 -2.8919,7.4603 -6.5583,7.4603 z" + fill="#ffffff" + id="path137" /><path + d="m 98.0293,26.1707 h 15.6637 c 3.776,0 6.966,0.6036 9.583,1.805 2.61,1.2013 4.567,2.8774 5.864,5.0223 1.296,2.1449 1.95,4.6004 1.95,7.3665 0,2.7075 -0.677,5.163 -2.031,7.3606 -1.354,2.2035 -3.414,3.9441 -6.185,5.2275 -2.771,1.2834 -6.203,1.928 -10.305,1.928 H 98.0293 Z m 14.3787,21.4138 c 2.542,0 4.499,-0.6505 5.864,-1.9457 1.366,-1.301 2.049,-3.0708 2.049,-5.3153 0,-2.0805 -0.609,-3.739 -1.825,-4.9814 -1.216,-1.2424 -3.058,-1.8694 -5.52,-1.8694 h -4.9 v 14.1118 z" + fill="#ffffff" + id="path139" /><path + d="m 154.541,54.8456 c -2.169,-0.5743 -4.126,-1.4065 -5.864,-2.5024 v -6.8097 c 1.314,1.0372 3.075,1.8929 5.284,2.5668 2.209,0.6681 4.344,1.0021 6.409,1.0021 0.964,0 1.693,-0.1289 2.186,-0.3868 0.494,-0.2578 0.741,-0.5684 0.741,-0.9259 0,-0.4102 -0.132,-0.7501 -0.402,-1.0256 -0.27,-0.2754 -0.792,-0.504 -1.566,-0.6974 l -4.82,-1.1076 c -2.76,-0.6563 -4.717,-1.5647 -5.881,-2.7309 -1.165,-1.1604 -1.745,-2.6841 -1.745,-4.5711 0,-1.5882 0.505,-2.9653 1.527,-4.1433 1.015,-1.1779 2.461,-2.0863 4.337,-2.7251 1.877,-0.6446 4.068,-0.9669 6.587,-0.9669 2.249,0 4.309,0.2461 6.186,0.7384 1.876,0.4923 3.425,1.1193 4.659,1.887 v 6.4406 c -1.263,-0.7677 -2.709,-1.3713 -4.361,-1.8285 -1.647,-0.4512 -3.339,-0.6739 -5.084,-0.6739 -2.519,0 -3.775,0.4395 -3.775,1.3127 0,0.4103 0.195,0.715 0.585,0.9201 0.39,0.2051 1.107,0.4161 2.146,0.6388 l 4.016,0.7384 c 2.623,0.463 4.579,1.2776 5.864,2.4379 1.286,1.1604 1.928,2.8775 1.928,5.1513 0,2.4906 -1.061,4.4656 -3.19,5.9307 -2.129,1.4651 -5.147,2.1976 -9.06,2.1976 -2.301,-0.0058 -4.538,-0.293 -6.707,-0.8673 z" + fill="#ffffff" + id="path141" /><path + d="m 182.978,53.9839 c -2.3,-1.1487 -4.039,-2.7075 -5.198,-4.6766 -1.159,-1.9691 -1.744,-4.1843 -1.744,-6.6457 0,-2.4613 0.602,-4.6648 1.807,-6.6046 1.205,-1.9398 2.972,-3.4635 5.302,-4.5711 2.329,-1.1076 5.112,-1.6585 8.354,-1.6585 4.016,0 7.35,0.8615 10.001,2.5844 v 7.5072 c -0.935,-0.6564 -2.026,-1.1897 -3.271,-1.5999 -1.245,-0.4102 -2.576,-0.6154 -3.999,-0.6154 -2.49,0 -4.435,0.463 -5.841,1.3948 -1.406,0.9318 -2.111,2.1449 -2.111,3.651 0,1.4768 0.682,2.6841 2.048,3.6335 1.366,0.9435 3.345,1.4182 5.944,1.4182 1.337,0 2.657,-0.1993 3.959,-0.5919 1.297,-0.3985 2.416,-0.8849 3.351,-1.4593 v 7.261 c -2.943,1.805 -6.357,2.7075 -10.242,2.7075 -3.27,-0.0117 -6.059,-0.586 -8.36,-1.7346 z" + fill="#ffffff" + id="path143" /><path + d="m 211.518,53.9841 c -2.318,-1.1486 -4.085,-2.7192 -5.302,-4.7176 -1.216,-1.9984 -1.83,-4.2253 -1.83,-6.6867 0,-2.4613 0.608,-4.659 1.83,-6.587 1.222,-1.9281 2.978,-3.4401 5.285,-4.536 2.3,-1.0959 5.049,-1.6409 8.233,-1.6409 3.185,0 5.933,0.545 8.234,1.6409 2.301,1.0959 4.057,2.5962 5.262,4.5125 1.205,1.9164 1.807,4.114 1.807,6.6047 0,2.4613 -0.602,4.6883 -1.807,6.6866 -1.205,1.9984 -2.967,3.569 -5.285,4.7176 -2.318,1.1487 -5.055,1.723 -8.216,1.723 -3.162,0 -5.899,-0.5685 -8.211,-1.7171 z m 12.204,-7.2786 c 0.976,-0.9962 1.469,-2.3148 1.469,-3.9557 0,-1.6409 -0.488,-2.9478 -1.469,-3.9148 -0.975,-0.9728 -2.307,-1.4592 -3.993,-1.4592 -1.716,0 -3.059,0.4864 -4.04,1.4592 -0.975,0.9729 -1.463,2.2739 -1.463,3.9148 0,1.6409 0.488,2.9595 1.463,3.9557 0.976,0.9963 2.324,1.5003 4.04,1.5003 1.686,-0.0059 3.018,-0.504 3.993,-1.5003 z" + fill="#ffffff" + id="path145" /><path + d="m 259.17,31.3395 v 8.8609 c -1.021,-0.6857 -2.341,-1.0256 -3.976,-1.0256 -2.141,0 -3.793,0.6623 -4.941,1.9867 -1.153,1.3245 -1.727,3.3873 -1.727,6.1768 v 7.5482 h -9.84 V 30.8883 h 9.64 v 7.6302 c 0.533,-2.7896 1.4,-4.8465 2.593,-6.1769 1.188,-1.3244 2.725,-1.9866 4.596,-1.9866 1.417,0 2.634,0.3282 3.655,0.9845 z" + fill="#ffffff" + id="path147" /><path + d="m 291.864,25.3503 v 29.5363 h -9.841 v -5.3739 c -0.832,2.0218 -2.094,3.5631 -3.792,4.6179 -1.699,1.0491 -3.799,1.5765 -6.289,1.5765 -2.226,0 -4.165,-0.5509 -5.824,-1.6585 -1.658,-1.1076 -2.937,-2.6254 -3.838,-4.5535 -0.895,-1.9281 -1.349,-4.1081 -1.349,-6.546 -0.028,-2.5141 0.448,-4.7704 1.429,-6.7688 0.976,-1.9984 2.358,-3.5572 4.137,-4.6766 1.779,-1.1193 3.81,-1.6819 6.088,-1.6819 4.688,0 7.832,2.0804 9.438,6.2354 V 25.3503 Z m -11.309,21.1912 c 1.004,-0.9963 1.503,-2.2914 1.503,-3.8737 0,-1.5296 -0.488,-2.7779 -1.463,-3.7331 -0.976,-0.9552 -2.313,-1.4358 -3.994,-1.4358 -1.658,0 -2.983,0.4864 -3.976,1.4592 -0.993,0.9729 -1.486,2.2328 -1.486,3.7917 0,1.5589 0.493,2.8306 1.486,3.8151 0.993,0.9845 2.301,1.4768 3.936,1.4768 1.658,-0.0058 2.989,-0.504 3.994,-1.5002 z" + fill="#ffffff" + id="path149" /><path + d="m 139.382,33.4432 c 2.709,0 4.906,-2.0151 4.906,-4.5008 0,-2.4857 -2.197,-4.5007 -4.906,-4.5007 -2.71,0 -4.906,2.015 -4.906,4.5007 0,2.4857 2.196,4.5008 4.906,4.5008 z" + fill="#ffffff" + id="path151" /><path + d="m 134.472,36.5435 c 3.006,1.3244 6.736,1.383 9.811,0 v 18.4719 h -9.811 z" + fill="#ffffff" + id="path153" /></g></g><path + d="m 61.7958,16.494 c -4.7222,-2.2094 -9.7714,-3.8151 -15.0502,-4.7294 -0.6483,1.1721 -1.4057,2.7486 -1.9279,4.0027 -5.6115,-0.8439 -11.1714,-0.8439 -16.6797,0 -0.5221,-1.2541 -1.2967,-2.8306 -1.9508,-4.0027 -5.2845,0.9143 -10.3395,2.5259 -15.0617,4.7411 C 1.60078,30.8988 -0.981215,44.9344 0.309785,58.7707 6.62708,63.4883 12.7493,66.3541 18.7682,68.2294 c 1.4861,-2.0453 2.8115,-4.2195 3.9533,-6.5109 -2.1746,-0.8263 -4.2574,-1.846 -6.2254,-3.0298 0.5221,-0.3868 1.0328,-0.7912 1.5262,-1.2073 12.0034,5.6143 25.0454,5.6143 36.9054,0 0.4992,0.4161 1.0098,0.8205 1.5262,1.2073 -1.9738,1.1896 -4.0623,2.2093 -6.2369,3.0357 1.1418,2.2855 2.4615,4.4656 3.9533,6.5108 6.0247,-1.8753 12.1526,-4.741 18.4699,-9.4645 C 74.155,42.7309 70.0525,28.8242 61.7958,16.494 Z m -37.439,33.7675 c -3.6033,0 -6.5583,-3.3639 -6.5583,-7.4603 0,-4.0964 2.8919,-7.4661 6.5583,-7.4661 3.6665,0 6.6214,3.3638 6.5583,7.4661 0.0057,4.0964 -2.8918,7.4603 -6.5583,7.4603 z m 24.2364,0 c -3.6033,0 -6.5583,-3.3639 -6.5583,-7.4603 0,-4.0964 2.8918,-7.4661 6.5583,-7.4661 3.6664,0 6.6214,3.3638 6.5583,7.4661 0,4.0964 -2.8919,7.4603 -6.5583,7.4603 z" + fill="#7289da" + id="path316" + transform="matrix(0.14732889,0,0,0.1473333,59.747728,84.998373)" + clip-path="url(#clipPath437)" + style="fill:#7289da;fill-opacity:1" /><g + aria-label="Join us" + id="text232" + style="font-weight:bold;font-stretch:ultra-expanded;font-size:5.98733px;font-family:'ABC Ginto Nord Bold';-inkscape-font-specification:'ABC Ginto Nord Bold, Bold Ultra-Expanded';fill:#7289da;stroke-width:0.264147"><path + d="m 75.055954,93.217428 c 1.059757,0 1.802186,-0.484974 1.802186,-1.616579 v -2.71226 h -1.496832 v 2.37697 c 0,0.484973 -0.305354,0.736441 -0.772366,0.736441 -0.347265,0 -0.562809,-0.131721 -0.69453,-0.24548 v 1.125618 c 0.185607,0.167645 0.60472,0.33529 1.161542,0.33529 z" + id="path892" /><path + d="m 79.624282,93.199466 c 1.454921,0 2.281172,-0.844213 2.281172,-1.915945 0,-1.07772 -0.826251,-1.86206 -2.281172,-1.86206 -1.454922,0 -2.28716,0.790327 -2.28716,1.86206 0,1.065744 0.832238,1.915945 2.28716,1.915945 z m 0,-1.095681 c -0.514911,0 -0.820265,-0.323316 -0.820265,-0.796315 0,-0.472999 0.305354,-0.78434 0.820265,-0.78434 0.508923,0 0.814277,0.311341 0.814277,0.78434 0,0.472999 -0.305354,0.796315 -0.814277,0.796315 z" + id="path894" /><path + d="m 83.144829,89.595094 c 0.461024,0 0.78434,-0.245481 0.78434,-0.598733 0,-0.341278 -0.323316,-0.586759 -0.78434,-0.586759 -0.467012,0 -0.802303,0.245481 -0.802303,0.586759 0,0.353252 0.335291,0.598733 0.802303,0.598733 z m 0.724466,3.484626 V 89.816625 H 82.4024 v 3.263095 z" + id="path896" /><path + d="m 87.407809,89.421461 c -0.730454,0 -1.173516,0.329303 -1.418997,1.029821 v -0.87415 h -1.448934 v 3.502588 h 1.460909 v -1.646516 c 0,-0.544847 0.24548,-0.826252 0.724467,-0.826252 0.431088,0 0.658606,0.275417 0.658606,0.802302 v 1.670466 h 1.466896 v -1.981807 c 0,-1.101668 -0.496948,-1.676452 -1.442947,-1.676452 z" + id="path898" /><path + d="m 93.832213,91.301483 c 0,0.532872 -0.239493,0.808289 -0.700517,0.808289 -0.44905,0 -0.652619,-0.24548 -0.652619,-0.778353 v -1.754287 h -1.466896 v 2.113527 c 0,0.969947 0.496948,1.514794 1.436959,1.514794 0.664594,0 1.149567,-0.293379 1.383073,-0.8502 v 0.724467 h 1.466896 v -3.502588 h -1.466896 z" + id="path900" /><path + d="m 97.729969,93.199466 c 1.191478,0 1.826135,-0.478986 1.826135,-1.185491 0,-0.69453 -0.419113,-0.975935 -1.161542,-1.107656 l -0.598733,-0.107772 c -0.287391,-0.05987 -0.407138,-0.0958 -0.407138,-0.227519 0,-0.119746 0.161658,-0.191594 0.562809,-0.191594 0.53886,0 1.047783,0.15567 1.407022,0.365227 V 89.80465 c -0.341277,-0.209556 -0.910074,-0.383189 -1.616579,-0.383189 -1.137592,0 -1.856072,0.44905 -1.856072,1.14358 0,0.532872 0.293379,0.868163 1.137593,1.065745 l 0.718479,0.161658 c 0.245481,0.05987 0.29338,0.143696 0.29338,0.251467 0,0.0958 -0.125734,0.191595 -0.437076,0.191595 -0.610707,0 -1.347149,-0.215544 -1.742313,-0.520898 v 0.993897 c 0.520898,0.317329 1.185492,0.490961 1.874035,0.490961 z" + id="path902" /></g></g><style + id="style834">.st0{fill:#FFFFFF;}</style></svg> diff --git a/pydis_site/static/images/resources/duck_pond_404.jpg b/pydis_site/static/images/resources/duck_pond_404.jpg Binary files differnew file mode 100644 index 00000000..29bcf1d6 --- /dev/null +++ b/pydis_site/static/images/resources/duck_pond_404.jpg diff --git a/pydis_site/static/images/sponsors/netcup.png b/pydis_site/static/images/sponsors/netcup.png Binary files differnew file mode 100644 index 00000000..e5dff196 --- /dev/null +++ b/pydis_site/static/images/sponsors/netcup.png diff --git a/pydis_site/static/js/collapsibles.js b/pydis_site/static/js/collapsibles.js new file mode 100644 index 00000000..1df0b9fe --- /dev/null +++ b/pydis_site/static/js/collapsibles.js @@ -0,0 +1,67 @@ +/* +A utility for creating simple collapsible cards. + +To see this in action, go to /resources or /pages/guides/pydis-guides/contributing/bot/ + +// HOW TO USE THIS // +First, import this file and the corresponding css file into your template. + + <link rel="stylesheet" href="{% static "css/collapsibles.css" %}"> + <script defer src="{% static "js/collapsibles.js" %}"></script> + +Next, you'll need some HTML that these scripts can interact with. + +<div class="card"> + <button type="button" class="card-header collapsible"> + <span class="card-header-title subtitle is-6 my-2 ml-2">Your headline</span> + <span class="card-header-icon"> + <i class="fas fa-fw fa-angle-down title is-5" aria-hidden="true"></i> + </span> + </button> + <div class="collapsible-content collapsed"> + <div class="card-content"> + You can put anything you want here. Lists, more divs, flexboxes, images, whatever. + </div> + </div> +</div> + +That's it! Collapsing stuff should now work. + */ + +document.addEventListener("DOMContentLoaded", () => { + const contentContainers = document.getElementsByClassName("collapsible-content"); + for (const container of contentContainers) { + // Close any collapsibles that are marked as initially collapsed + if (container.classList.contains("collapsed")) { + container.style.maxHeight = "0px"; + // Set maxHeight to the size of the container on all other containers. + } else { + container.style.maxHeight = container.scrollHeight + "px"; + } + } + + // Listen for click events, and collapse or explode + const headers = document.getElementsByClassName("collapsible"); + for (const header of headers) { + const content = header.nextElementSibling; + const icon = header.querySelector(".card-header-icon i"); + + // Any collapsibles that are not initially collapsed needs an icon switch. + if (!content.classList.contains("collapsed")) { + icon.classList.remove("fas", "fa-angle-down"); + icon.classList.add("far", "fa-window-minimize"); + } + + header.addEventListener("click", () => { + if (content.style.maxHeight !== "0px"){ + content.style.maxHeight = "0px"; + icon.classList.remove("far", "fa-window-minimize"); + icon.classList.add("fas", "fa-angle-down"); + } else { + content.style.maxHeight = content.scrollHeight + "px"; + icon.classList.remove("fas", "fa-angle-down"); + icon.classList.add("far", "fa-window-minimize"); + } + }); + } +}); diff --git a/pydis_site/static/js/content/page.js b/pydis_site/static/js/content/page.js deleted file mode 100644 index 366a033c..00000000 --- a/pydis_site/static/js/content/page.js +++ /dev/null @@ -1,13 +0,0 @@ -document.addEventListener("DOMContentLoaded", () => { - const headers = document.getElementsByClassName("collapsible"); - for (const header of headers) { - header.addEventListener("click", () => { - var content = header.nextElementSibling; - if (content.style.maxHeight){ - content.style.maxHeight = null; - } else { - content.style.maxHeight = content.scrollHeight + "px"; - } - }); - } -}); diff --git a/pydis_site/static/js/fuzzysort/LICENSE.md b/pydis_site/static/js/fuzzysort/LICENSE.md new file mode 100644 index 00000000..a3b9d9d7 --- /dev/null +++ b/pydis_site/static/js/fuzzysort/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Stephen Kamenar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pydis_site/static/js/fuzzysort/fuzzysort.js b/pydis_site/static/js/fuzzysort/fuzzysort.js new file mode 100644 index 00000000..ba01ae63 --- /dev/null +++ b/pydis_site/static/js/fuzzysort/fuzzysort.js @@ -0,0 +1,636 @@ +/* + fuzzysort.js https://github.com/farzher/fuzzysort + SublimeText-like Fuzzy Search + + fuzzysort.single('fs', 'Fuzzy Search') // {score: -16} + fuzzysort.single('test', 'test') // {score: 0} + fuzzysort.single('doesnt exist', 'target') // null + + fuzzysort.go('mr', [{file:'Monitor.cpp'}, {file:'MeshRenderer.cpp'}], {key:'file'}) + // [{score:-18, obj:{file:'MeshRenderer.cpp'}}, {score:-6009, obj:{file:'Monitor.cpp'}}] + + fuzzysort.go('mr', ['Monitor.cpp', 'MeshRenderer.cpp']) + // [{score: -18, target: "MeshRenderer.cpp"}, {score: -6009, target: "Monitor.cpp"}] + + fuzzysort.highlight(fuzzysort.single('fs', 'Fuzzy Search'), '<b>', '</b>') + // <b>F</b>uzzy <b>S</b>earch +*/ + +// UMD (Universal Module Definition) for fuzzysort +;(function(root, UMD) { + if(typeof define === 'function' && define.amd) define([], UMD) + else if(typeof module === 'object' && module.exports) module.exports = UMD() + else root.fuzzysort = UMD() +})(this, function UMD() { function fuzzysortNew(instanceOptions) { + + var fuzzysort = { + + single: function(search, target, options) { ;if(search=='farzher')return{target:"farzher was here (^-^*)/",score:0,indexes:[0,1,2,3,4,5,6]} + if(!search) return null + if(!isObj(search)) search = fuzzysort.getPreparedSearch(search) + + if(!target) return null + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + var allowTypo = options && options.allowTypo!==undefined ? options.allowTypo + : instanceOptions && instanceOptions.allowTypo!==undefined ? instanceOptions.allowTypo + : true + var algorithm = allowTypo ? fuzzysort.algorithm : fuzzysort.algorithmNoTypo + return algorithm(search, target, search[0]) + }, + + go: function(search, targets, options) { ;if(search=='farzher')return[{target:"farzher was here (^-^*)/",score:0,indexes:[0,1,2,3,4,5,6],obj:targets?targets[0]:null}] + if(!search) return noResults + search = fuzzysort.prepareSearch(search) + var searchLowerCode = search[0] + + var threshold = options && options.threshold || instanceOptions && instanceOptions.threshold || -9007199254740991 + var limit = options && options.limit || instanceOptions && instanceOptions.limit || 9007199254740991 + var allowTypo = options && options.allowTypo!==undefined ? options.allowTypo + : instanceOptions && instanceOptions.allowTypo!==undefined ? instanceOptions.allowTypo + : true + var algorithm = allowTypo ? fuzzysort.algorithm : fuzzysort.algorithmNoTypo + var resultsLen = 0; var limitedCount = 0 + var targetsLen = targets.length + + // This code is copy/pasted 3 times for performance reasons [options.keys, options.key, no keys] + + // options.keys + if(options && options.keys) { + var scoreFn = options.scoreFn || defaultScoreFn + var keys = options.keys + var keysLen = keys.length + for(var i = targetsLen - 1; i >= 0; --i) { var obj = targets[i] + var objResults = new Array(keysLen) + for (var keyI = keysLen - 1; keyI >= 0; --keyI) { + var key = keys[keyI] + var target = getValue(obj, key) + if(!target) { objResults[keyI] = null; continue } + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + objResults[keyI] = algorithm(search, target, searchLowerCode) + } + objResults.obj = obj // before scoreFn so scoreFn can use it + var score = scoreFn(objResults) + if(score === null) continue + if(score < threshold) continue + objResults.score = score + if(resultsLen < limit) { q.add(objResults); ++resultsLen } + else { + ++limitedCount + if(score > q.peek().score) q.replaceTop(objResults) + } + } + + // options.key + } else if(options && options.key) { + var key = options.key + for(var i = targetsLen - 1; i >= 0; --i) { var obj = targets[i] + var target = getValue(obj, key) + if(!target) continue + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + var result = algorithm(search, target, searchLowerCode) + if(result === null) continue + if(result.score < threshold) continue + + // have to clone result so duplicate targets from different obj can each reference the correct obj + result = {target:result.target, _targetLowerCodes:null, _nextBeginningIndexes:null, score:result.score, indexes:result.indexes, obj:obj} // hidden + + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result.score > q.peek().score) q.replaceTop(result) + } + } + + // no keys + } else { + for(var i = targetsLen - 1; i >= 0; --i) { var target = targets[i] + if(!target) continue + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + var result = algorithm(search, target, searchLowerCode) + if(result === null) continue + if(result.score < threshold) continue + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result.score > q.peek().score) q.replaceTop(result) + } + } + } + + if(resultsLen === 0) return noResults + var results = new Array(resultsLen) + for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll() + results.total = resultsLen + limitedCount + return results + }, + + goAsync: function(search, targets, options) { + var canceled = false + var p = new Promise(function(resolve, reject) { ;if(search=='farzher')return resolve([{target:"farzher was here (^-^*)/",score:0,indexes:[0,1,2,3,4,5,6],obj:targets?targets[0]:null}]) + if(!search) return resolve(noResults) + search = fuzzysort.prepareSearch(search) + var searchLowerCode = search[0] + + var q = fastpriorityqueue() + var iCurrent = targets.length - 1 + var threshold = options && options.threshold || instanceOptions && instanceOptions.threshold || -9007199254740991 + var limit = options && options.limit || instanceOptions && instanceOptions.limit || 9007199254740991 + var allowTypo = options && options.allowTypo!==undefined ? options.allowTypo + : instanceOptions && instanceOptions.allowTypo!==undefined ? instanceOptions.allowTypo + : true + var algorithm = allowTypo ? fuzzysort.algorithm : fuzzysort.algorithmNoTypo + var resultsLen = 0; var limitedCount = 0 + function step() { + if(canceled) return reject('canceled') + + var startMs = Date.now() + + // This code is copy/pasted 3 times for performance reasons [options.keys, options.key, no keys] + + // options.keys + if(options && options.keys) { + var scoreFn = options.scoreFn || defaultScoreFn + var keys = options.keys + var keysLen = keys.length + for(; iCurrent >= 0; --iCurrent) { + if(iCurrent%1000/*itemsPerCheck*/ === 0) { + if(Date.now() - startMs >= 10/*asyncInterval*/) { + isNode?setImmediate(step):setTimeout(step) + return + } + } + + var obj = targets[iCurrent] + var objResults = new Array(keysLen) + for (var keyI = keysLen - 1; keyI >= 0; --keyI) { + var key = keys[keyI] + var target = getValue(obj, key) + if(!target) { objResults[keyI] = null; continue } + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + objResults[keyI] = algorithm(search, target, searchLowerCode) + } + objResults.obj = obj // before scoreFn so scoreFn can use it + var score = scoreFn(objResults) + if(score === null) continue + if(score < threshold) continue + objResults.score = score + if(resultsLen < limit) { q.add(objResults); ++resultsLen } + else { + ++limitedCount + if(score > q.peek().score) q.replaceTop(objResults) + } + } + + // options.key + } else if(options && options.key) { + var key = options.key + for(; iCurrent >= 0; --iCurrent) { + if(iCurrent%1000/*itemsPerCheck*/ === 0) { + if(Date.now() - startMs >= 10/*asyncInterval*/) { + isNode?setImmediate(step):setTimeout(step) + return + } + } + + var obj = targets[iCurrent] + var target = getValue(obj, key) + if(!target) continue + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + var result = algorithm(search, target, searchLowerCode) + if(result === null) continue + if(result.score < threshold) continue + + // have to clone result so duplicate targets from different obj can each reference the correct obj + result = {target:result.target, _targetLowerCodes:null, _nextBeginningIndexes:null, score:result.score, indexes:result.indexes, obj:obj} // hidden + + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result.score > q.peek().score) q.replaceTop(result) + } + } + + // no keys + } else { + for(; iCurrent >= 0; --iCurrent) { + if(iCurrent%1000/*itemsPerCheck*/ === 0) { + if(Date.now() - startMs >= 10/*asyncInterval*/) { + isNode?setImmediate(step):setTimeout(step) + return + } + } + + var target = targets[iCurrent] + if(!target) continue + if(!isObj(target)) target = fuzzysort.getPrepared(target) + + var result = algorithm(search, target, searchLowerCode) + if(result === null) continue + if(result.score < threshold) continue + if(resultsLen < limit) { q.add(result); ++resultsLen } + else { + ++limitedCount + if(result.score > q.peek().score) q.replaceTop(result) + } + } + } + + if(resultsLen === 0) return resolve(noResults) + var results = new Array(resultsLen) + for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll() + results.total = resultsLen + limitedCount + resolve(results) + } + + isNode?setImmediate(step):step() //setTimeout here is too slow + }) + p.cancel = function() { canceled = true } + return p + }, + + highlight: function(result, hOpen, hClose) { + if(typeof hOpen == 'function') return fuzzysort.highlightCallback(result, hOpen) + if(result === null) return null + if(hOpen === undefined) hOpen = '<b>' + if(hClose === undefined) hClose = '</b>' + var highlighted = '' + var matchesIndex = 0 + var opened = false + var target = result.target + var targetLen = target.length + var matchesBest = result.indexes + for(var i = 0; i < targetLen; ++i) { var char = target[i] + if(matchesBest[matchesIndex] === i) { + ++matchesIndex + if(!opened) { opened = true + highlighted += hOpen + } + + if(matchesIndex === matchesBest.length) { + highlighted += char + hClose + target.substr(i+1) + break + } + } else { + if(opened) { opened = false + highlighted += hClose + } + } + highlighted += char + } + + return highlighted + }, + highlightCallback: function(result, cb) { + if(result === null) return null + var target = result.target + var targetLen = target.length + var indexes = result.indexes + var highlighted = '' + var matchI = 0 + var indexesI = 0 + var opened = false + var result = [] + for(var i = 0; i < targetLen; ++i) { var char = target[i] + if(indexes[indexesI] === i) { + ++indexesI + if(!opened) { opened = true + result.push(highlighted); highlighted = '' + } + + if(indexesI === indexes.length) { + highlighted += char + result.push(cb(highlighted, matchI++)); highlighted = '' + result.push(target.substr(i+1)) + break + } + } else { + if(opened) { opened = false + result.push(cb(highlighted, matchI++)); highlighted = '' + } + } + highlighted += char + } + return result + }, + + prepare: function(target) { + if(!target) return {target: '', _targetLowerCodes: [0/*this 0 doesn't make sense. here because an empty array causes the algorithm to deoptimize and run 50% slower!*/], _nextBeginningIndexes: null, score: null, indexes: null, obj: null} // hidden + return {target:target, _targetLowerCodes:fuzzysort.prepareLowerCodes(target), _nextBeginningIndexes:null, score:null, indexes:null, obj:null} // hidden + }, + prepareSlow: function(target) { + if(!target) return {target: '', _targetLowerCodes: [0/*this 0 doesn't make sense. here because an empty array causes the algorithm to deoptimize and run 50% slower!*/], _nextBeginningIndexes: null, score: null, indexes: null, obj: null} // hidden + return {target:target, _targetLowerCodes:fuzzysort.prepareLowerCodes(target), _nextBeginningIndexes:fuzzysort.prepareNextBeginningIndexes(target), score:null, indexes:null, obj:null} // hidden + }, + prepareSearch: function(search) { + if(!search) search = '' + return fuzzysort.prepareLowerCodes(search) + }, + + + + // Below this point is only internal code + // Below this point is only internal code + // Below this point is only internal code + // Below this point is only internal code + + + + getPrepared: function(target) { + if(target.length > 999) return fuzzysort.prepare(target) // don't cache huge targets + var targetPrepared = preparedCache.get(target) + if(targetPrepared !== undefined) return targetPrepared + targetPrepared = fuzzysort.prepare(target) + preparedCache.set(target, targetPrepared) + return targetPrepared + }, + getPreparedSearch: function(search) { + if(search.length > 999) return fuzzysort.prepareSearch(search) // don't cache huge searches + var searchPrepared = preparedSearchCache.get(search) + if(searchPrepared !== undefined) return searchPrepared + searchPrepared = fuzzysort.prepareSearch(search) + preparedSearchCache.set(search, searchPrepared) + return searchPrepared + }, + + algorithm: function(searchLowerCodes, prepared, searchLowerCode) { + var targetLowerCodes = prepared._targetLowerCodes + var searchLen = searchLowerCodes.length + var targetLen = targetLowerCodes.length + var searchI = 0 // where we at + var targetI = 0 // where you at + var typoSimpleI = 0 + var matchesSimpleLen = 0 + + // very basic fuzzy match; to remove non-matching targets ASAP! + // walk through target. find sequential matches. + // if all chars aren't found then exit + for(;;) { + var isMatch = searchLowerCode === targetLowerCodes[targetI] + if(isMatch) { + matchesSimple[matchesSimpleLen++] = targetI + ++searchI; if(searchI === searchLen) break + searchLowerCode = searchLowerCodes[typoSimpleI===0?searchI : (typoSimpleI===searchI?searchI+1 : (typoSimpleI===searchI-1?searchI-1 : searchI))] + } + + ++targetI; if(targetI >= targetLen) { // Failed to find searchI + // Check for typo or exit + // we go as far as possible before trying to transpose + // then we transpose backwards until we reach the beginning + for(;;) { + if(searchI <= 1) return null // not allowed to transpose first char + if(typoSimpleI === 0) { // we haven't tried to transpose yet + --searchI + var searchLowerCodeNew = searchLowerCodes[searchI] + if(searchLowerCode === searchLowerCodeNew) continue // doesn't make sense to transpose a repeat char + typoSimpleI = searchI + } else { + if(typoSimpleI === 1) return null // reached the end of the line for transposing + --typoSimpleI + searchI = typoSimpleI + searchLowerCode = searchLowerCodes[searchI + 1] + var searchLowerCodeNew = searchLowerCodes[searchI] + if(searchLowerCode === searchLowerCodeNew) continue // doesn't make sense to transpose a repeat char + } + matchesSimpleLen = searchI + targetI = matchesSimple[matchesSimpleLen - 1] + 1 + break + } + } + } + + var searchI = 0 + var typoStrictI = 0 + var successStrict = false + var matchesStrictLen = 0 + + var nextBeginningIndexes = prepared._nextBeginningIndexes + if(nextBeginningIndexes === null) nextBeginningIndexes = prepared._nextBeginningIndexes = fuzzysort.prepareNextBeginningIndexes(prepared.target) + var firstPossibleI = targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1] + + // Our target string successfully matched all characters in sequence! + // Let's try a more advanced and strict test to improve the score + // only count it as a match if it's consecutive or a beginning character! + if(targetI !== targetLen) for(;;) { + if(targetI >= targetLen) { + // We failed to find a good spot for this search char, go back to the previous search char and force it forward + if(searchI <= 0) { // We failed to push chars forward for a better match + // transpose, starting from the beginning + ++typoStrictI; if(typoStrictI > searchLen-2) break + if(searchLowerCodes[typoStrictI] === searchLowerCodes[typoStrictI+1]) continue // doesn't make sense to transpose a repeat char + targetI = firstPossibleI + continue + } + + --searchI + var lastMatch = matchesStrict[--matchesStrictLen] + targetI = nextBeginningIndexes[lastMatch] + + } else { + var isMatch = searchLowerCodes[typoStrictI===0?searchI : (typoStrictI===searchI?searchI+1 : (typoStrictI===searchI-1?searchI-1 : searchI))] === targetLowerCodes[targetI] + if(isMatch) { + matchesStrict[matchesStrictLen++] = targetI + ++searchI; if(searchI === searchLen) { successStrict = true; break } + ++targetI + } else { + targetI = nextBeginningIndexes[targetI] + } + } + } + + { // tally up the score & keep track of matches for highlighting later + if(successStrict) { var matchesBest = matchesStrict; var matchesBestLen = matchesStrictLen } + else { var matchesBest = matchesSimple; var matchesBestLen = matchesSimpleLen } + var score = 0 + var lastTargetI = -1 + for(var i = 0; i < searchLen; ++i) { var targetI = matchesBest[i] + // score only goes down if they're not consecutive + if(lastTargetI !== targetI - 1) score -= targetI + lastTargetI = targetI + } + if(!successStrict) { + score *= 1000 + if(typoSimpleI !== 0) score += -20/*typoPenalty*/ + } else { + if(typoStrictI !== 0) score += -20/*typoPenalty*/ + } + score -= targetLen - searchLen + prepared.score = score + prepared.indexes = new Array(matchesBestLen); for(var i = matchesBestLen - 1; i >= 0; --i) prepared.indexes[i] = matchesBest[i] + + return prepared + } + }, + + algorithmNoTypo: function(searchLowerCodes, prepared, searchLowerCode) { + var targetLowerCodes = prepared._targetLowerCodes + var searchLen = searchLowerCodes.length + var targetLen = targetLowerCodes.length + var searchI = 0 // where we at + var targetI = 0 // where you at + var matchesSimpleLen = 0 + + // very basic fuzzy match; to remove non-matching targets ASAP! + // walk through target. find sequential matches. + // if all chars aren't found then exit + for(;;) { + var isMatch = searchLowerCode === targetLowerCodes[targetI] + if(isMatch) { + matchesSimple[matchesSimpleLen++] = targetI + ++searchI; if(searchI === searchLen) break + searchLowerCode = searchLowerCodes[searchI] + } + ++targetI; if(targetI >= targetLen) return null // Failed to find searchI + } + + var searchI = 0 + var successStrict = false + var matchesStrictLen = 0 + + var nextBeginningIndexes = prepared._nextBeginningIndexes + if(nextBeginningIndexes === null) nextBeginningIndexes = prepared._nextBeginningIndexes = fuzzysort.prepareNextBeginningIndexes(prepared.target) + var firstPossibleI = targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1] + + // Our target string successfully matched all characters in sequence! + // Let's try a more advanced and strict test to improve the score + // only count it as a match if it's consecutive or a beginning character! + if(targetI !== targetLen) for(;;) { + if(targetI >= targetLen) { + // We failed to find a good spot for this search char, go back to the previous search char and force it forward + if(searchI <= 0) break // We failed to push chars forward for a better match + + --searchI + var lastMatch = matchesStrict[--matchesStrictLen] + targetI = nextBeginningIndexes[lastMatch] + + } else { + var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI] + if(isMatch) { + matchesStrict[matchesStrictLen++] = targetI + ++searchI; if(searchI === searchLen) { successStrict = true; break } + ++targetI + } else { + targetI = nextBeginningIndexes[targetI] + } + } + } + + { // tally up the score & keep track of matches for highlighting later + if(successStrict) { var matchesBest = matchesStrict; var matchesBestLen = matchesStrictLen } + else { var matchesBest = matchesSimple; var matchesBestLen = matchesSimpleLen } + var score = 0 + var lastTargetI = -1 + for(var i = 0; i < searchLen; ++i) { var targetI = matchesBest[i] + // score only goes down if they're not consecutive + if(lastTargetI !== targetI - 1) score -= targetI + lastTargetI = targetI + } + if(!successStrict) score *= 1000 + score -= targetLen - searchLen + prepared.score = score + prepared.indexes = new Array(matchesBestLen); for(var i = matchesBestLen - 1; i >= 0; --i) prepared.indexes[i] = matchesBest[i] + + return prepared + } + }, + + prepareLowerCodes: function(str) { + var strLen = str.length + var lowerCodes = [] // new Array(strLen) sparse array is too slow + var lower = str.toLowerCase() + for(var i = 0; i < strLen; ++i) lowerCodes[i] = lower.charCodeAt(i) + return lowerCodes + }, + prepareBeginningIndexes: function(target) { + var targetLen = target.length + var beginningIndexes = []; var beginningIndexesLen = 0 + var wasUpper = false + var wasAlphanum = false + for(var i = 0; i < targetLen; ++i) { + var targetCode = target.charCodeAt(i) + var isUpper = targetCode>=65&&targetCode<=90 + var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57 + var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum + wasUpper = isUpper + wasAlphanum = isAlphanum + if(isBeginning) beginningIndexes[beginningIndexesLen++] = i + } + return beginningIndexes + }, + prepareNextBeginningIndexes: function(target) { + var targetLen = target.length + var beginningIndexes = fuzzysort.prepareBeginningIndexes(target) + var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow + var lastIsBeginning = beginningIndexes[0] + var lastIsBeginningI = 0 + for(var i = 0; i < targetLen; ++i) { + if(lastIsBeginning > i) { + nextBeginningIndexes[i] = lastIsBeginning + } else { + lastIsBeginning = beginningIndexes[++lastIsBeginningI] + nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning + } + } + return nextBeginningIndexes + }, + + cleanup: cleanup, + new: fuzzysortNew, + } + return fuzzysort +} // fuzzysortNew + +// This stuff is outside fuzzysortNew, because it's shared with instances of fuzzysort.new() +var isNode = typeof require !== 'undefined' && typeof window === 'undefined' +var MyMap = Map||function(){var s=Object.create(null);this.get=function(k){return s[k]};this.set=function(k,val){s[k]=val;return this};this.clear=function(){s=Object.create(null)}} +var preparedCache = new MyMap() +var preparedSearchCache = new MyMap() +var noResults = []; noResults.total = 0 +var matchesSimple = []; var matchesStrict = [] +function cleanup() { preparedCache.clear(); preparedSearchCache.clear(); matchesSimple = []; matchesStrict = [] } +function defaultScoreFn(a) { + var max = -9007199254740991 + for (var i = a.length - 1; i >= 0; --i) { + var result = a[i]; if(result === null) continue + var score = result.score + if(score > max) max = score + } + if(max === -9007199254740991) return null + return max +} + +// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop] +// prop = 'key1.key2' 10ms +// prop = ['key1', 'key2'] 27ms +function getValue(obj, prop) { + var tmp = obj[prop]; if(tmp !== undefined) return tmp + var segs = prop + if(!Array.isArray(prop)) segs = prop.split('.') + var len = segs.length + var i = -1 + while (obj && (++i < len)) obj = obj[segs[i]] + return obj +} + +function isObj(x) { return typeof x === 'object' } // faster as a function + +// Hacked version of https://github.com/lemire/FastPriorityQueue.js +var fastpriorityqueue=function(){var r=[],o=0,e={};function n(){for(var e=0,n=r[e],c=1;c<o;){var f=c+1;e=c,f<o&&r[f].score<r[c].score&&(e=f),r[e-1>>1]=r[e],c=1+(e<<1)}for(var a=e-1>>1;e>0&&n.score<r[a].score;a=(e=a)-1>>1)r[e]=r[a];r[e]=n}return e.add=function(e){var n=o;r[o++]=e;for(var c=n-1>>1;n>0&&e.score<r[c].score;c=(n=c)-1>>1)r[n]=r[c];r[n]=e},e.poll=function(){if(0!==o){var e=r[0];return r[0]=r[--o],n(),e}},e.peek=function(e){if(0!==o)return r[0]},e.replaceTop=function(o){r[0]=o,n()},e}; +var q = fastpriorityqueue() // reuse this, except for async, it needs to make its own + +return fuzzysortNew() +}) // UMD + +// TODO: (performance) wasm version!? +// TODO: (performance) threads? +// TODO: (performance) avoid cache misses +// TODO: (performance) preparedCache is a memory leak +// TODO: (like sublime) backslash === forwardslash +// TODO: (like sublime) spaces: "a b" should do 2 searches 1 for a and 1 for b +// TODO: (scoring) garbage in targets that allows most searches to strict match need a penality +// TODO: (performance) idk if allowTypo is optimized diff --git a/pydis_site/static/js/resources/resources.js b/pydis_site/static/js/resources/resources.js new file mode 100644 index 00000000..d6cc8128 --- /dev/null +++ b/pydis_site/static/js/resources/resources.js @@ -0,0 +1,367 @@ +"use strict"; + +// Filters that are currently selected +var activeFilters = { + topics: [], + type: [], + "payment-tiers": [], + difficulty: [] +}; + +// Options for fuzzysort +const fuzzysortOptions = { + allowTypo: true, // Allow our users to make typos + titleThreshold: -10000, // The threshold for the fuzziness on title matches. Closer to 0 is stricter. + descriptionThreshold: -500, // The threshold for the fuzziness on description matches. +}; + +/* Add a filter, and update the UI */ +function addFilter(filterName, filterItem) { + var filterIndex = activeFilters[filterName].indexOf(filterItem); + if (filterIndex === -1) { + activeFilters[filterName].push(filterItem); + } + updateUI(); +} + +/* Remove all filters, and update the UI */ +function removeAllFilters() { + activeFilters = { + topics: [], + type: [], + "payment-tiers": [], + difficulty: [] + }; + $("#resource-search input").val(""); + updateUI(); +} + +/* Remove a filter, and update the UI */ +function removeFilter(filterName, filterItem) { + var filterIndex = activeFilters[filterName].indexOf(filterItem); + if (filterIndex !== -1) { + activeFilters[filterName].splice(filterIndex, 1); + } + updateUI(); +} + +/* Check if there are no filters */ +function noFilters() { + return ( + activeFilters.topics.length === 0 && + activeFilters.type.length === 0 && + activeFilters["payment-tiers"].length === 0 && + activeFilters.difficulty.length === 0 + ); +} + +/* Get the params out of the URL and use them. This is run when the page loads. */ +function deserializeURLParams() { + let searchParams = new window.URLSearchParams(window.location.search); + + // Add the search query to the search bar. + if (searchParams.has("search")) { + let searchQuery = searchParams.get("search"); + $("#resource-search input").val(searchQuery); + $(".close-filters-button").show(); + } + + // Work through the parameters and add them to the filter object + $.each(Object.keys(activeFilters), function(_, filterType) { + let paramFilterContent = searchParams.get(filterType); + + if (paramFilterContent !== null) { + // We use split here because we always want an array, not a string. + let paramFilterArray = paramFilterContent.split(","); + + // Update the corresponding filter UI, so it reflects the internal state. + let filterAdded = false; + $(paramFilterArray).each(function(_, filter) { + // Catch special cases. + if (String(filter) === "rickroll" && filterType === "type") { + window.location.href = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + } else if (String(filter) === "sneakers" && filterType === "topics") { + window.location.href = "https://www.youtube.com/watch?v=NNZscmNE9QI"; + + // If the filter is valid, mirror it to the UI. + } else if (validFilters.hasOwnProperty(filterType) && validFilters[filterType].includes(String(filter))) { + let checkbox = $(`.filter-checkbox[data-filter-name='${filterType}'][data-filter-item='${filter}']`); + let filterTag = $(`.filter-box-tag[data-filter-name='${filterType}'][data-filter-item='${filter}']`); + let resourceTags = $(`.resource-tag[data-filter-name='${filterType}'][data-filter-item='${filter}']`); + checkbox.prop("checked", true); + filterTag.show(); + resourceTags.addClass("active"); + activeFilters[filterType].push(filter); + filterAdded = true; + } + }); + + // Ditch all the params from the URL, and recalculate the URL params + updateURL(); + + // If we've added a filter, hide stuff + if (filterAdded) { + $(".no-tags-selected.tag").hide(); + $(".close-filters-button").show(); + } + } + }); +} + +/* Show or hide the duckies, depending on whether or not there are any resources visible. */ +function updateDuckies() { + let visibleResources = Boolean($(".resource-box:visible").length); + if (!visibleResources) { + $(".no-resources-found").show(); + } else { + $(".no-resources-found").hide(); + } +} + + +/* Update the URL with new parameters */ +function updateURL() { + let searchQuery = $("#resource-search input").val(); + + // If there's no active filtering parameters, we can return early. + if (noFilters() && searchQuery.length === 0) { + window.history.replaceState(null, document.title, './'); + return; + } + + // Iterate through and get rid of empty ones + let searchParams = new URLSearchParams(activeFilters); + $.each(activeFilters, function(filterType, filters) { + if (filters.length === 0) { + searchParams.delete(filterType); + } + }); + + // Add the search query, if necessary. + if (searchQuery.length > 0) { + searchParams.set("search", searchQuery); + } + + // Now update the URL + window.history.replaceState(null, document.title, `?${searchParams.toString()}`); +} + +/* Apply search terms */ +function filterBySearch(resourceItems) { + let searchQuery = $("#resource-search input").val(); + + /* Show and update the tag if there's a search query */ + if (searchQuery) { + let tag = $(".tag.search-query"); + let tagText = $(".tag.search-query span"); + tagText.text(`Search: ${searchQuery}`); + tag.show(); + $(".close-filters-button").show(); + } + + resourceItems.filter(function() { + // Get the resource title and description + let title = $(this).attr("data-resource-name"); + let description = $(this).find("p").text(); + + // Run a fuzzy search. Does the title or description match the query? + let titleMatch = fuzzysort.single(searchQuery, title, fuzzysortOptions); + titleMatch = Boolean(titleMatch) && titleMatch.score > fuzzysortOptions.titleThreshold; + + let descriptionMatch = fuzzysort.single(searchQuery, description, fuzzysortOptions); + descriptionMatch = Boolean(descriptionMatch) && descriptionMatch.score > fuzzysortOptions.descriptionThreshold; + + return titleMatch || descriptionMatch; + }).show(); +} + +/* Update the resources to match 'active_filters' */ +function updateUI() { + let resources = $('.resource-box'); + let filterTags = $('.filter-box-tag'); + let resourceTags = $('.resource-tag'); + let noTagsSelected = $(".no-tags-selected.tag"); + let closeFiltersButton = $(".close-filters-button"); + let searchQuery = $("#resource-search input").val(); + let searchTag = $(".tag.search-query"); + + // Update the URL to match the new filters. + updateURL(); + + // If there's nothing in the filters, we can return early. + if (noFilters()) { + // If we have a searchQuery, we need to run all resources through a search. + if (searchQuery.length > 0) { + resources.hide(); + noTagsSelected.hide(); + filterBySearch(resources); + } else { + resources.show(); + noTagsSelected.show(); + closeFiltersButton.hide(); + $(".tag.search-query").hide(); + } + + filterTags.hide(); + resourceTags.removeClass("active"); + $(`.filter-checkbox:checked`).prop("checked", false); + updateDuckies(); + + return; + } else { + // Hide everything + $('.filter-box-tag').hide(); + $('.resource-tag').removeClass("active"); + noTagsSelected.show(); + closeFiltersButton.hide(); + + // Now conditionally show the stuff we want + $.each(activeFilters, function(filterType, filters) { + $.each(filters, function(index, filter) { + // Show a corresponding filter box tag + $(`.filter-box-tag[data-filter-name=${filterType}][data-filter-item=${filter}]`).show(); + + // Make corresponding resource tags active + $(`.resource-tag[data-filter-name=${filterType}][data-filter-item=${filter}]`).addClass("active"); + + // Hide the "No filters selected" tag. + noTagsSelected.hide(); + + // Show the close filters button + closeFiltersButton.show(); + }); + }); + } + + // Otherwise, hide everything and then filter the resources to decide what to show. + resources.hide(); + let filteredResources = resources.filter(function() { + let validation = { + topics: false, + type: false, + 'payment-tiers': false, + difficulty: false + }; + let resourceBox = $(this); + + // Validate the filters + $.each(activeFilters, function(filterType, filters) { + // If the filter list is empty, this passes validation. + if (filters.length === 0) { + validation[filterType] = true; + return; + } + + // Otherwise, we need to check if one of the classes exist. + $.each(filters, function(index, filter) { + if (resourceBox.hasClass(`${filterType}-${filter}`)) { + validation[filterType] = true; + } + }); + }); + + // If validation passes, show the resource. + if (Object.values(validation).every(Boolean)) { + return true; + } else { + return false; + } + }); + + // Run the items we've found through the search filter, if necessary. + if (searchQuery.length > 0) { + filterBySearch(filteredResources); + } else { + filteredResources.show(); + searchTag.hide(); + } + + // Gotta update those duckies! + updateDuckies(); +} + +// Executed when the page has finished loading. +document.addEventListener("DOMContentLoaded", function () { + /* Check if the user has navigated to one of the old resource pages, + like pydis.com/resources/communities. In this case, we'll rewrite + the URL before we do anything else. */ + let resourceTypeInput = $("#resource-type-input").val(); + if (resourceTypeInput !== "None") { + window.history.replaceState(null, document.title, `../?type=${resourceTypeInput}`); + } + + // Update the filters on page load to reflect URL parameters. + $('.filter-box-tag').hide(); + deserializeURLParams(); + updateUI(); + + // If this is a mobile device, collapse all the categories to win back some screen real estate. + if (screen.width < 480) { + let categoryHeaders = $(".filter-category-header .collapsible-content"); + let icons = $('.filter-category-header button .card-header-icon i'); + categoryHeaders.addClass("no-transition collapsed"); + icons.removeClass(["far", "fa-window-minimize"]); + icons.addClass(["fas", "fa-angle-down"]); + + // Wait 10ms before removing this class, or else the transition will animate due to a race condition. + setTimeout(() => { categoryHeaders.removeClass("no-transition"); }, 10); + } + + // When you type into the search bar, trigger an UI update. + $("#resource-search input").on("input", function() { + updateUI(); + }); + + // If you click on the div surrounding the filter checkbox, it clicks the corresponding checkbox. + $('.filter-panel').on("click",function(event) { + let hitsCheckbox = Boolean(String(event.target)); + + if (!hitsCheckbox) { + let checkbox = $(this).find(".filter-checkbox"); + checkbox.prop("checked", !checkbox.prop("checked")); + checkbox.trigger("change"); + } + }); + + // If you click on one of the tags in the filter box, it unchecks the corresponding checkbox. + $('.filter-box-tag').on("click", function() { + let filterItem = this.dataset.filterItem; + let filterName = this.dataset.filterName; + let checkbox = $(`.filter-checkbox[data-filter-name='${filterName}'][data-filter-item='${filterItem}']`); + + removeFilter(filterName, filterItem); + checkbox.prop("checked", false); + }); + + // If you click on one of the tags in the resource cards, it clicks the corresponding checkbox. + $('.resource-tag').on("click", function() { + let filterItem = this.dataset.filterItem; + let filterName = this.dataset.filterName; + let checkbox = $(`.filter-checkbox[data-filter-name='${filterName}'][data-filter-item='${filterItem}']`); + + if (!$(this).hasClass("active")) { + addFilter(filterName, filterItem); + checkbox.prop("checked", true); + } else { + removeFilter(filterName, filterItem); + checkbox.prop("checked", false); + } + }); + + // When you click the little gray x, remove all filters. + $(".close-filters-button").on("click", function() { + removeAllFilters(); + }); + + // When checkboxes are toggled, trigger a filter update. + $('.filter-checkbox').on("change", function (event) { + let filterItem = this.dataset.filterItem; + let filterName = this.dataset.filterName; + + if (this.checked && !activeFilters[filterName].includes(filterItem)) { + addFilter(filterName, filterItem); + } else if (!this.checked && activeFilters[filterName].includes(filterItem)) { + removeFilter(filterName, filterItem); + } + }); +}); diff --git a/pydis_site/templates/base/base.html b/pydis_site/templates/base/base.html index 906fc577..b7322f12 100644 --- a/pydis_site/templates/base/base.html +++ b/pydis_site/templates/base/base.html @@ -24,10 +24,7 @@ <title>Python Discord | {% block title %}Website{% endblock %}</title> {% bulma %} - - {# Font-awesome here is defined explicitly so that we can have Pro #} - <script src="https://kit.fontawesome.com/ae6a3152d8.js"></script> - + {% font_awesome %} <link rel="stylesheet" href="{% static "css/base/base.css" %}"> {% block head %}{% endblock %} diff --git a/pydis_site/templates/base/footer.html b/pydis_site/templates/base/footer.html index bca43b5d..0bc93578 100644 --- a/pydis_site/templates/base/footer.html +++ b/pydis_site/templates/base/footer.html @@ -1,7 +1,7 @@ <footer class="footer has-background-dark has-text-light"> <div class="content has-text-centered"> <p> - Powered by <a href="https://www.linode.com/?r=3bc18ce876ff43ea31f201b91e8e119c9753f085"><span id="linode-logo">Linode</span></a><br>Built with <a href="https://www.djangoproject.com/"><span id="django-logo">django</span></a> and <a href="https://bulma.io"><span id="bulma-logo">Bulma</span></a> <br/> © {% now "Y" %} <span id="pydis-text">Python Discord</span> + Powered by <a href="https://www.linode.com/?r=3bc18ce876ff43ea31f201b91e8e119c9753f085"><span id="linode-logo">Linode</span></a> and <a href="https://www.netcup.eu/"><span id="netcup-logo">netcup</span></a><br>Built with <a href="https://www.djangoproject.com/"><span id="django-logo">django</span></a> and <a href="https://bulma.io"><span id="bulma-logo">Bulma</span></a> <br/> © {% now "Y" %} <span id="pydis-text">Python Discord</span> </p> </div> </footer> diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html index 4b68dd6c..d7fb4f4c 100644 --- a/pydis_site/templates/base/navbar.html +++ b/pydis_site/templates/base/navbar.html @@ -67,9 +67,6 @@ <a class="navbar-item" href="{% url "resources:index" %}"> Resources </a> - <a class="navbar-item" href="{% url "resources:resources" category="tools" %}"> - Tools - </a> <a class="navbar-item" href="{% url "events:index" %}"> Events </a> @@ -79,6 +76,9 @@ <a class="navbar-item" href="{% url "content:page_category" location="frequently-asked-questions" %}"> FAQ </a> + <a class="navbar-item" href="{% url "content:page_category" location="guides" %}"> + Guides + </a> <a class="navbar-item" href="{% url 'home:timeline' %}"> Timeline </a> diff --git a/pydis_site/templates/content/base.html b/pydis_site/templates/content/base.html index 00f4fce4..4a19a275 100644 --- a/pydis_site/templates/content/base.html +++ b/pydis_site/templates/content/base.html @@ -7,7 +7,8 @@ <meta property="og:type" content="website" /> <meta property="og:description" content="{{ page_description }}" /> <link rel="stylesheet" href="{% static "css/content/page.css" %}"> - <script src="{% static "js/content/page.js" %}"></script> + <link rel="stylesheet" href="{% static "css/collapsibles.css" %}"> + <script src="{% static "js/collapsibles.js" %}"></script> {% endblock %} {% block content %} diff --git a/pydis_site/templates/content/dropdown.html b/pydis_site/templates/content/dropdown.html index d81e29dc..13c89c68 100644 --- a/pydis_site/templates/content/dropdown.html +++ b/pydis_site/templates/content/dropdown.html @@ -1,4 +1,4 @@ -<div class="dropdown is-pulled-right is-right" id="dropdown"> +<div class="dropdown is-pulled-right is-right" id="dropdown" style="z-index: 1"> <div class="dropdown-trigger"> <a aria-haspopup="true" aria-controls="subarticle-menu"> <span>Sub-Articles</span> diff --git a/pydis_site/templates/events/index.html b/pydis_site/templates/events/index.html index 158ec56b..db3e32f7 100644 --- a/pydis_site/templates/events/index.html +++ b/pydis_site/templates/events/index.html @@ -8,8 +8,11 @@ {% block event_content %} <div class="box"> - <h2 class="title is-4">Code Jams</h2> - <p>Each year, we organize at least one code jam, one during the summer and sometimes one during the winter. During these events, members of our community will work together in teams to create something amazing using a technology we picked for them. One such technology that was picked for the Summer 2021 Code Jam was text user interfaces (TUIS), where teams could pick from a pre-approved list of frameworks.</p> + <h2 class="title is-4"><a href="{% url "events:page" path="code-jams" %}">Code Jams</a></h2> + <div class="notification is-success"> + The <b>2022 Summer Code Jam</b> is currently underway and you can still enter! <b>The qualifier is open until July 13</b>; check out the details <a href="{% url "events:page" path="code-jams/9" %}">here</a>. + </div> + <p>Every year we hold a community-wide Summer Code Jam. For this event, members of our community are assigned to teams to collaborate and create something amazing using a technology we picked for them. One such technology that was picked for the Summer 2021 Code Jam was text user interfaces (TUIs), where teams could pick from a pre-approved list of frameworks.</p> <p>To help fuel the creative process, we provide a specific theme, like <strong>Think Inside the Box</strong> or <strong>Early Internet</strong>. At the end of the Code Jam, the projects are judged by Python Discord server staff members and guest judges from the larger Python community. The judges will consider creativity, code quality, teamwork, and adherence to the theme.</p> <p>If you want to read more about Code Jams, visit our <a href="{% url "events:page" path="code-jams" %}">Code Jam info page</a> or watch this video showcasing the best projects created during the <strong>Winter Code Jam 2020: Ancient Technology</strong>:</p> <iframe width="560" height="315" src="https://www.youtube.com/embed/8fbZsGrqBzo" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe> diff --git a/pydis_site/templates/events/pages/code-jams/9/_index.html b/pydis_site/templates/events/pages/code-jams/9/_index.html new file mode 100644 index 00000000..7c57b799 --- /dev/null +++ b/pydis_site/templates/events/pages/code-jams/9/_index.html @@ -0,0 +1,117 @@ +{% extends "events/base_sidebar.html" %} + +{% load static %} + +{% block title %}Summer Code Jam 2022{% endblock %} + +{% block breadcrumb %} + <li><a href="{% url "events:index" %}">Events</a></li> + <li><a href="{% url "events:page" path="code-jams" %}">Code Jams</a></li> + <li class="is-active"><a href="#">Summer Code Jam 2022</a></li> +{% endblock %} + +{% block event_content %} + <p>Once a year we host a code jam for members of our server to participate in. The code jam is an event where we place you + in a team with 5 other random server members. You then have 11 days to code some sort of application or program in Python. + Your program must use the specified technology/framework and incorporate the theme chosen by the server. + </p> + <p> + After the 11 days are complete, your team has 4 days to finish documentation and create a video presentation showcasing + and walking through the program that your team has created. More details and specifics of this will be released within the next 2 weeks. + </p> + + <h3 id="important-dates"><a href="#important-dates">Important Dates</a></h3> + <ul> + <li><strike>Saturday, June 18 - Form to submit theme suggestions opens</strike></li> + <li><strike>Wednesday, June 29 - The Qualifier is released</strike></li> + <li>Wednesday, July 6 - Voting for the theme opens</li> + <li>Wednesday, July 13 - The Qualifier closes</li> + <li>Thursday, July 21 - Code Jam Begins</li> + <li>Sunday, July 31 - Coding portion of the jam ends</li> + <li>Sunday, August 4 - Code Jam submissions are closed</li> + </ul> + <h3 id="how-to-join"><a href="#how-to-join">How to Join</a></h3> + <p> + Before being able to join the code jam, you must complete a qualifier which tests your knowledge in Python. + The qualifier can be found <a href="https://github.com/python-discord/code-jam-qualifier-9/" title="Code Jam 9 qualifier repository" target="_blank" rel="noopener">on our GitHub</a> + and once completed you should submit your solution using the <a href="https://forms.pythondiscord.com/form/cj9-qualifier" target="_blank" rel="noopener">sign-up form</a>. + </p> + <h3 id="technology"><a href="#technology">Technology</a></h3> + <p> + The chosen technology/tech stack for this year is <strong>WebSockets</strong>. + Each team must make use of <a href="{% url "events:page" path="code-jams/9/frameworks" %}">the approved frameworks</a> to create a WebSockets-based app. + For more information of websockets, check out <a href="https://en.wikipedia.org/wiki/WebSocket" target="_blank" rel="noopener">this wikipedia article</a>. + </p> + + <h3 id="prizes"><a href="#prizes">Prizes</a></h3> + <p> + Our Code Jam Sponsors have provided prizes for the winners of the code jam. + Also, thanks to our Patreon patrons supporting this server, we are able to send members of the winning teams + Python Discord t-shirts and possibly other goodies. + </p> + + <div class="card mb-4"> + <div class="card-content"> + <div class="media"> + <div class="media-left" style="max-width:150px"> + <img src="{% static "images/events/DO_Logo_Vertical_Blue.png" %}" alt="Digital Ocean"> + </div> + <div class="media-content"> + <p class="subtitle has-link"><a href="https://www.digitalocean.com/" target="_blank" rel="noopener">DigitalOcean</a></p> + <p class="is-italic"> + Scalable compute platform with add-on storage, security, and monitoring capabilities. + We make it simple to launch in the cloud and scale up as you grow—whether you’re running one virtual machine or ten thousand. + </p> + <p><strong>Prizes</strong><br> + DigitalOcean credits to the members of a winning team.</p> + </div> + </div> + </div> + </div> + + <div class="card mb-4"> + <div class="card-content"> + <div class="media"> + <div class="media-left" style="max-width:150px"> + <img src="{% static "images/sponsors/jetbrains.png" %}" alt="JetBrains"> + </div> + <div class="media-content"> + <p class="subtitle has-link"><a href="https://www.jetbrains.com/" target="_blank" rel="noopener">JetBrains</a></p> + <p class="is-italic"> + Whatever platform or language you work with, JetBrains has a development tool for you. + We help developers work faster by automating common, repetitive tasks to enable them to stay focused on code design and the big picture. + We provide tools to explore and familiarize with code bases faster. Our products make it easy for you to take care of quality during all stages of development and spend less time on maintenance tasks. + </p> + <p><strong>Prizes</strong><br> + 1-year JetBrain licenses to the members of a winning team.</p> + </div> + </div> + </div> + </div> + + <div class="card mb"> + <div class="card-content"> + <div class="media"> + <div class="media-left" style="max-width:150px"> + <img src="{% static "images/events/Replit.png" %}" alt="Replit"> + </div> + <div class="media-content"> + <p class="subtitle has-link"><a href="https://www.replit.com" target="_blank" rel="noopener">Replit</a></p> + <p class="is-italic">Start coding instantly, right from your browser. + With GitHub integration and support for nearly every major programming language, Replit is the best place to code. + Our mission is to bring the next billion software creators online. + We build powerful, simple tools and platforms for learners, educators, and developers. + </p> + <p><strong>Prizes</strong><br> + Three months of the Replit hacker plan to the members of a winning team.</p> + </div> + </div> + </div> + </div> +{% endblock %} + +{% block sidebar %} + + {% include "events/sidebar/code-jams/9.html" %} + +{% endblock %} diff --git a/pydis_site/templates/events/pages/code-jams/9/frameworks.html b/pydis_site/templates/events/pages/code-jams/9/frameworks.html new file mode 100644 index 00000000..355bf9c3 --- /dev/null +++ b/pydis_site/templates/events/pages/code-jams/9/frameworks.html @@ -0,0 +1,148 @@ +{% extends "events/base_sidebar.html" %} + +{% load static %} + +{% block title %}Summer Code Jam 2022{% endblock %} + +{% block breadcrumb %} + <li><a href="{% url "events:index" %}">Events</a></li> + <li><a href="{% url "events:page" path="code-jams" %}">Code Jams</a></li> + <li><a href="{% url "events:page" path="code-jams/9" %}">Summer Code Jam 2022</a></li> + <li class="is-active"><a href="#">Approved Frameworks</a></li> +{% endblock %} + +{% block event_content %} + <p>Below is the list of approved frameworks that you can use for the code jam. + Please work with your team to choose a library that everyone can and want to develop with. + If there is a library not listed below that you think should be here, you're welcome to discuss it with the Events Team over at <a href="https://discord.gg/HnGd3znxhJ">the server</a>. + </p> + + <div class="notification is-info is-light"> + <p>Most of the below frameworks implement what is called the ASGI Specification. + This specification documents how the frameworks should interact with ASGI servers. + You are also allowed to <strong>work with the ASGI specification directly</strong> without a framework, if your team so chooses to. + Refer to the <a href="https://asgi.readthedocs.io/en/latest/">specification online</a>. + </p> + </div> + + <h3 id="approved-frameworks"><a href="#approved-frameworks">Approved Frameworks</a></h3> + + <div class="card mb-4"> + <div class="card-content"> + <div class="content"> + <p class="subtitle">FastAPI</p> + <p>FastAPI is a modern web framework great for WebSockets based on standard Python type hints which provides great editor support.</p> + </div> + </div> + <div class="card-footer"> + <a href="https://fastapi.tiangolo.com/advanced/websockets" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/tiangolo/fastapi" class="card-footer-item"><i class="fab fa-github"></i> GitHub</a> + </div> + </div> + + <div class="card mb-4"> + <div class="card-content"> + <div class="content"> + <p class="subtitle">Starlette</p> + <p>Starlette is a lightweight ASGI framework/toolkit, which is ideal for building async web services in Python. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://www.starlette.io/websockets" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/encode/starlette" class="card-footer-item"><i class="fab fa-github"></i> GitHub</a> + </div> + </div> + + <div class="card mb-4"> + <div class="card-content"> + <div class="content"> + <p class="subtitle">websockets</p> + <p>websockets is a library for building both WebSocket clients and servers with focus on simplicity and performance. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://websockets.readthedocs.io/en/stable" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/aaugustin/websockets" class="card-footer-item"><i class="fab fa-github"></i> GitHub</a> + </div> + </div> + + <div class="card mb-4"> + <div class="card-content"> + <div class="content"> + <p class="subtitle">Django Channels</p> + <p>Django Channels adds WebSocket-support to Django - built on ASGI like other web frameworks. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://channels.readthedocs.io/en/stable" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/django/channels" class="card-footer-item"><i class="fab fa-github"></i> GitHub</a> + </div> + </div> + + <div class="card mb-4"> + <div class="card-content"> + <div class="content"> + <p class="subtitle">Flask-SocketIO</p> + <p>Flask-SocketIO gives Flask applications access to low latency bi-directional communications between the clients and the server. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://flask-socketio.readthedocs.io/en/latest" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/miguelgrinberg/flask-socketio" class="card-footer-item"><i class="fab fa-github"></i> GitHub</a> + </div> + </div> + + <div class="card mb-4"> + <div class="card-content"> + <div class="content"> + <p class="subtitle">wsproto</p> + <p>wsproto is a pure-Python WebSocket protocol stack written to be as flexible as possible by having the user build the bridge to the I/O. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://python-hyper.org/projects/wsproto/en/stable" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/python-hyper/wsproto" class="card-footer-item"><i class="fab fa-github"></i> GitHub</a> + </div> + </div> + + <div class="card mb-4"> + <div class="card-content"> + <div class="content"> + <p class="subtitle">Starlite</p> + <p>Starlite is a light and flexible ASGI API framework, using Starlette and Pydantic as foundations. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://starlite-api.github.io/starlite" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/starlite-api/starlite" class="card-footer-item"><i class="fab fa-github"></i> GitHub</a> + </div> + </div> + + <div class="card mb-4"> + <div class="card-content"> + <div class="content"> + <p class="subtitle">Sanic</p> + <p>Sanic is an ASGI compliant web framework designed for speed and simplicity. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://sanic.dev/en/guide/advanced/websockets.html" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/sanic-org/sanic" class="card-footer-item"><i class="fab fa-github"></i> GitHub</a> + </div> + </div> + + +{% endblock %} + +{% block sidebar %} + + {% include "events/sidebar/code-jams/9.html" %} + +{% endblock %} diff --git a/pydis_site/templates/events/pages/code-jams/9/rules.html b/pydis_site/templates/events/pages/code-jams/9/rules.html new file mode 100644 index 00000000..72c0372e --- /dev/null +++ b/pydis_site/templates/events/pages/code-jams/9/rules.html @@ -0,0 +1,69 @@ +{% extends "events/base_sidebar.html" %} + +{% block title %}Summer Code Jam 2022{% endblock %} + +{% block breadcrumb %} + <li><a href="{% url "events:index" %}">Events</a></li> + <li><a href="{% url "events:page" path="code-jams" %}">Code Jams</a></li> + <li><a href="{% url "events:page" path="code-jams/9" %}">Summer Code Jam 2022</a></li> + <li class="is-active"><a href="#">Rules</a></li> +{% endblock %} + +{% block event_content %} +<ol> + <li><p>Your solution must use one of the approved frameworks (a list will be released soon). It is not permitted to circumvent this rule by e.g. using the approved framework as a wrapper for another framework.</p></li> + <li><p>Your solution should be platform agnostic. For example, if you use filepaths in your submission, use <code>pathlib</code> to create platform agnostic Path objects instead of hardcoding the paths.</p></li> + <li> + <p> + You must document precisely how to install and run your project. + This should be as easy as possible, which means you should consider using dependency managers like <code>pipenv</code> or <code>poetry</code>. + We would also encourage you to use <code>docker</code> and <code>docker-compose</code> to containerize your project, but this isn't a requirement. + </p> + </li> + <li> + You must get contributions from every member of your team, if you have an issue with someone on your team please contact a member of the administration team. + These contributions do not necessarily have to be code, for example it's absolutely fine for someone to contribute management, documentation, graphics or audio. + <strong> + Team members that do not contribute will be removed from the Code Jam, and will not receive their share of any prizes the team may win. + They may also be barred from entering future events. + </strong> + </li> + <li><p>You must use GitHub as source control.</p></li> + <li> + <p> + All code and assets must be compatible with the <a href="https://en.wikipedia.org/wiki/MIT_License">MIT license</a>. + This is because we will be merging your submission into our <code>summer-code-jam-2022</code> repo at the end of the jam, + and this repo is licensed with the MIT license. + <strong>Projects that include assets that are incompatible with this license may be disqualified.</strong> + </p> + </li> + <li><p>All code must be written and committed within the time constrictions of the jam. Late commits may be reverted, so make sure you leave enough time to bug test your program.</p></li> + <li> + <p> + Use English as the main language for your project, including names, comments, documentation, and commit messages. + Any text displayed in your application should also be in English, + although you are allowed to provide the user with options for internationalisation and translation. + </p> + </li> + <li> + <p> + Your team, once the coding portion of the code jam is complete, must create a video presentation that showcases and explains your final product. + This must be in a video format and must be uploaded somewhere for the judges to view (i.e. unlisted Youtube video, Vimeo, etc.) + The video can be as simple as a screen recording with annotated text. + Teams who do not submit a final video presentation may be disqualified. + </p> + </li> +</ol> + +<blockquote> + Please note that our regular + <a href="/pages/rules">community rules</a> and <a href="/pages/code-of-conduct">code of conduct</a> + also apply during the event and that we reserve the right to make changes to these rules at any time. +</blockquote> +{% endblock %} + +{% block sidebar %} + + {% include "events/sidebar/code-jams/9.html" %} + +{% endblock %} diff --git a/pydis_site/templates/events/pages/code-jams/_index.html b/pydis_site/templates/events/pages/code-jams/_index.html index 207d4b9a..74efcfaa 100644 --- a/pydis_site/templates/events/pages/code-jams/_index.html +++ b/pydis_site/templates/events/pages/code-jams/_index.html @@ -8,6 +8,12 @@ {% block title %}Code Jams{% endblock %} {% block event_content %} + <div class="block"> + <div class="notification is-success"> + The <b>2022 Summer Code Jam</b> is currently underway and you can still enter! <b>The qualifier is open until July 13</b>; check out the details <a href="{% url "events:page" path="code-jams/9" %}">here</a>. + </div> + </div> + <p> If you've been around the server for a while, or you just happened to join at the right time, you may have heard of something known as a Code Jam. @@ -31,7 +37,7 @@ <h2 class="title is-4" id="how-often-do-these-happen"><a href="#how-often-do-these-happen">How often do these happen?</a></h2> <p> - Our Code Jams happen twice a year. We have a Winter Jam and a Summer Jam. + Our Code Jams happen once a year every summer. </p> <h2 class="title is-4" id="what-happens-if-i-have-to-drop-out"><a href="#what-happens-if-i-have-to-drop-out">What happens if I have to drop out?</a></h2> diff --git a/pydis_site/templates/events/sidebar/code-jams/7.html b/pydis_site/templates/events/sidebar/code-jams/7.html index d4615c2a..4aefdbd9 100644 --- a/pydis_site/templates/events/sidebar/code-jams/7.html +++ b/pydis_site/templates/events/sidebar/code-jams/7.html @@ -1,7 +1,7 @@ {% load static %} <div class="box"> - <img src="https://raw.githubusercontent.com/python-discord/branding/master/events/summer_code_jam_2020/summer%20cj%202020%20discord%20banner.png" alt="Summer Code Jam 2020"> + <img src="https://raw.githubusercontent.com/python-discord/branding/master/jams/summer_code_jam_2020/summer%20cj%202020%20discord%20banner.png" alt="Summer Code Jam 2020"> <p class="menu-label">Sponsors</p> <a href="https://www.djangoproject.com/" target="_blank"> <img src="https://static.djangoproject.com/img/logos/django-logo-positive.png" alt="Django"> diff --git a/pydis_site/templates/events/sidebar/code-jams/9.html b/pydis_site/templates/events/sidebar/code-jams/9.html new file mode 100644 index 00000000..2351973f --- /dev/null +++ b/pydis_site/templates/events/sidebar/code-jams/9.html @@ -0,0 +1,21 @@ +{% load static %} +<div class="panel"> + <p class="panel-heading">Important Links</p> + <a class="panel-block has-text-link" href="{% url "events:page" path="code-jams/9/rules" %}">Rules</a> + <a class="panel-block has-text-link" href="{% url "events:page" path="code-jams/9/frameworks" %}">Approved Frameworks</a> + <a class="panel-block has-text-link" href="{% url "events:page" path="code-jams/code-style-guide" %}">The Code Style Guide</a> + </ul> +</div> +<div class="box"> + <img src="{% static "images/events/summer_code_jam_2022/site_banner.png" %}" alt="Summer Code Jam 2022"> + <h4 class="menu-label">Our Sponsors</h4> + <a href="https://www.digitalocean.com/" target="_blank"> + <img src="{% static "images/events/DO_Logo_Vertical_Blue.png" %}" alt="Digital Ocean"> + </a> + <a href="https://jetbrains.com" target="_blank"> + <img src="{% static "images/sponsors/jetbrains.png" %}" alt="JetBrains"> + </a> + <a href="https://replit.com/" target="_blank"> + <img src="{% static "images/events/Replit.png" %}" alt="Replit"> + </a> +</div> diff --git a/pydis_site/templates/events/sidebar/code-jams/ongoing-code-jam.html b/pydis_site/templates/events/sidebar/code-jams/ongoing-code-jam.html index f4fa3a37..37569e57 100644 --- a/pydis_site/templates/events/sidebar/code-jams/ongoing-code-jam.html +++ b/pydis_site/templates/events/sidebar/code-jams/ongoing-code-jam.html @@ -1,8 +1,8 @@ {% load static %} <div class="box"> - <h4 class="menu-label">Ongoing Code Jam</h4> - <a href="{% url "events:page" path="code-jams/8" %}"> - <img src="{% static "images/events/summer_code_jam_2021/banner.png" %}" alt="Summer Code Jam 2021"> + <h4 class="menu-label">Upcoming Code Jam</h4> + <a href="{% url "events:page" path="code-jams/9" %}"> + <img src="{% static "images/events/summer_code_jam_2022/banner.png" %}" alt="Summer Code Jam 2022"> </a> </div> diff --git a/pydis_site/templates/events/sidebar/events-list.html b/pydis_site/templates/events/sidebar/events-list.html index 5dfe5dc2..8deac80e 100644 --- a/pydis_site/templates/events/sidebar/events-list.html +++ b/pydis_site/templates/events/sidebar/events-list.html @@ -1,10 +1,17 @@ <div class="box"> - <p class="menu-label">Event Calendar 2021</p> + <p class="menu-label">Event Calendar 2022</p> <ul class="menu-list"> - <li><a class="has-text-link" href="https://pyweek.org/31/" target="_blank" rel="noopener">March: PyWeek 31</a></li> - <li><a class="has-text-black" style="cursor: default;">May: Pixels</a></li> - <li><a class="has-text-link" href="{% url "events:page" path="code-jams/8" %}">July: Summer Code Jam</a></li> - <li><a class="has-text-link" href="https://pyweek.org/32/" target="_blank" rel="noopener">September: PyWeek 32</a></li> + <li><a class="has-text-link" href="https://pyweek.org/33/" target="_blank" rel="noopener">March: PyWeek 33</a></li> + <li><a class="has-text-link" href="{% url "events:page" path="code-jams/9" %}">July: Summer Code Jam</a></li> + <li><a class="has-text-link" href="https://pyweek.org/34/" target="_blank" rel="noopener">September: PyWeek 34</a></li> + <li><a class="has-text-black" style="cursor: default;">October: Pixels</a></li> <li><a class="has-text-black" style="cursor: default;">December: Advent of Code</a></li> </ul> </div> + +<div class="box"> + <p class="menu-label">Related Links</p> + <ul class="menu-list"> + <li><a class="has-text-link" href="{% url "events:page" path="code-jams" %}">Code Jams</a></li> + </ul> +</div> diff --git a/pydis_site/templates/events/sidebar/ongoing-event.html b/pydis_site/templates/events/sidebar/ongoing-event.html index 37dfdf77..e375fa38 100644 --- a/pydis_site/templates/events/sidebar/ongoing-event.html +++ b/pydis_site/templates/events/sidebar/ongoing-event.html @@ -1,8 +1,8 @@ {% load static %} <div class="box"> - <p class="menu-label">Ongoing Event</p> - <a href="{% url "events:page" path="code-jams/8" %}"> - <img src="{% static "images/events/summer_code_jam_2021/banner.png" %}" alt="Summer Code Jam 2021"> + <p class="menu-label">Upcoming Event</p> + <a href="{% url "events:page" path="code-jams/9" %}"> + <img src="{% static "images/events/summer_code_jam_2022/banner.png" %}" alt="Summer Code Jam 2022"> </a> </div> diff --git a/pydis_site/templates/home/index.html b/pydis_site/templates/home/index.html index 985ccae1..cdbac830 100644 --- a/pydis_site/templates/home/index.html +++ b/pydis_site/templates/home/index.html @@ -9,6 +9,13 @@ {% block content %} {% include "base/navbar.html" %} + <!-- Mobile-only Code Jam Banner --> + <section id="mobile-notice" class="is-primary is-hidden-tablet"> + <a href="/events/code-jams/9/"> + <img src="{% static "images/events/summer_code_jam_2022/front_page_banners/sign_up.png" %}" alt="Summer Code Jam 2022"> + </a> + </section> + <!-- Wave Hero --> <section id="wave-hero" class="section is-hidden-mobile"> @@ -37,7 +44,15 @@ ></iframe> </div> </div> + + {# Code Jam Banner #} + <div id="wave-hero-right" class="column is-half"> + <a href="/events/code-jams/9/"> + <img src="{% static "images/events/summer_code_jam_2022/front_page_banners/sign_up.png" %}" alt="Summer Code Jam 2022"> + </a> + </div> </div> + </div> {# Animated wave elements #} @@ -84,9 +99,9 @@ <div class="mini-timeline"> <i class="fa fa-asterisk"></i> <i class="fa fa-code"></i> - <i class="fab fa-python"></i> - <i class="fa fa-alien-monster"></i> - <i class="fa fa-duck"></i> + <i class="fab fa-lg fa-python"></i> + <i class="fab fa-discord"></i> + <i class="fa fa-sm fa-terminal"></i> <i class="fa fa-bug"></i> </div> @@ -173,6 +188,9 @@ Sponsors </h1> <div class="columns is-mobile is-multiline"> + <a href="https://www.netcup.eu/" class="column is-narrow"> + <img src="{% static "images/sponsors/netcup.png" %}" alt="netcup"/> + </a> <a href="https://www.linode.com/?r=3bc18ce876ff43ea31f201b91e8e119c9753f085" class="column is-narrow"> <img src="{% static "images/sponsors/linode.png" %}" alt="Linode"/> </a> diff --git a/pydis_site/templates/resources/resource_box.html b/pydis_site/templates/resources/resource_box.html index af7c8d65..5ca46296 100644 --- a/pydis_site/templates/resources/resource_box.html +++ b/pydis_site/templates/resources/resource_box.html @@ -1,6 +1,8 @@ {% load as_icon %} +{% load to_kebabcase %} +{% load get_category_icon %} -<div class="box" style="max-width: 800px;"> +<div class="box resource-box {{ resource.css_classes }}" data-resource-name="{{ resource.name }}"> {% if 'title_url' in resource %} <a href="{{ resource.title_url }}"> {% include "resources/resource_box_header.html" %} @@ -9,14 +11,69 @@ {% include "resources/resource_box_header.html" %} {% endif %} - <p class="is-italic">{{ resource.description|safe }}</p> + <p>{{ resource.description|safe }}</p> - {# Icons #} - {% for icon in resource.urls %} - <span class="icon is-size-4 is-medium" style="margin: 5px;"> - <a href="{{ icon.url }}"> - <i class="{{ icon.icon|as_icon }} is-size-3 resource-icon is-{{ icon.color }}"></i> - </a> - </span> - {% endfor %} + <div class="is-flex is-align-items-center"> + {# Add primary link #} + {% if "title_url" in resource %} + <span class="icon is-size-4" style="margin: 5px;"> + <a href="{{ resource.title_url }}"> + <i class="fas fa-external-link-alt fa-fw is-size-4 resource-icon is-hoverable is-primary"></i> + </a> + </span> + {% endif %} + + {# Add all additional icon #} + {% for icon in resource.urls %} + <span class="icon is-size-4" style="margin: 5px;"> + <a href="{{ icon.url }}"> + <i class="{{ icon.icon|as_icon }} fa-fw is-size-4 resource-icon is-hoverable is-{{ icon.color }}"></i> + </a> + </span> + {% endfor %} + + {# Tags #} + <div class="resource-tag-container is-flex ml-auto is-flex-wrap-wrap is-justify-content-end"> + {% for tag in resource.tags.topics %} + <span + class="tag resource-tag is-primary is-light ml-2 mt-2" + data-filter-name="topics" + data-filter-item="{{ tag|to_kebabcase }}" + > + <i class="{{ tag|title|get_category_icon }} mr-1"></i> + {{ tag|title }} + </span> + {% endfor %} + {% for tag in resource.tags.type %} + <span + class="tag resource-tag has-background-success-light has-text-success-dark ml-2 mt-2" + data-filter-name="type" + data-filter-item="{{ tag|to_kebabcase }}" + > + <i class="{{ tag|title|get_category_icon }} mr-1"></i> + {{ tag|title }} + </span> + {% endfor %} + {% for tag in resource.tags.payment_tiers %} + <span + class="tag resource-tag has-background-danger-light has-text-danger-dark ml-2 mt-2" + data-filter-name="payment-tiers" + data-filter-item="{{ tag|to_kebabcase }}" + > + <i class="{{ tag|title|get_category_icon }} mr-1"></i> + {{ tag|title }} + </span> + {% endfor %} + {% for tag in resource.tags.difficulty %} + <span + class="tag resource-tag has-background-info-light has-text-info-dark ml-2 mt-2" + data-filter-name="difficulty" + data-filter-item="{{ tag|to_kebabcase }}" + > + <i class="{{ tag|title|get_category_icon }} mr-1"></i> + {{ tag|title }} + </span> + {% endfor %} + </div> + </div> </div> 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 @@ <span class="is-size-4 has-text-weight-bold"> {% if 'title_image' in resource %} <img src="{{ resource.title_image }}" alt="" style="height: 50px; {{ resource.title_image_style }}"> - {% endif %} - {% if 'name' in resource %} + {% elif 'name' in resource %} {{ resource.name }} {% endif %} </span> diff --git a/pydis_site/templates/resources/resources.html b/pydis_site/templates/resources/resources.html index f1f487cf..101f9965 100644 --- a/pydis_site/templates/resources/resources.html +++ b/pydis_site/templates/resources/resources.html @@ -1,90 +1,180 @@ {% extends 'base/base.html' %} +{% load as_icon %} +{% load to_kebabcase %} +{% load get_category_icon %} {% load static %} {% block title %}Resources{% endblock %} {% block head %} - <link rel="stylesheet" href="{% static "css/resources/resources.css" %}"> + {# Inject a JSON object of all valid filter types from the view #} + <script> + const validFilters = {{ valid_filters | safe }} + </script> + + <link rel="stylesheet" href="{% static "css/resources/resources.css" %}"> + <link rel="stylesheet" href="{% static "css/collapsibles.css" %}"> + <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> + <script defer src="{% static "js/resources/resources.js" %}"></script> + <script defer src="{% static "js/collapsibles.js" %}"></script> + <script defer src="{% static "js/fuzzysort/fuzzysort.js" %}"></script> {% endblock %} {% block content %} - {% include "base/navbar.html" %} + {% include "base/navbar.html" %} + <input type="hidden" id="resource-type-input" value="{{ resource_type }}"> + <section class="section"> + <div class="columns is-variable is-6 is-centered"> + {# Filtering toolbox #} + <div class="column filtering-column is-one-third"> + <div class="content is-justify-content-center"> + <nav id="resource-filtering-panel" class="panel is-primary"> + <p class="panel-heading has-text-centered" id="filter-panel-header">Filter resources</p> + + {# Search bar #} + <p id="resource-search" class="control has-icons-left"> + <input class="input" placeholder="Search resources "> + <span class="icon is-small is-left"> + <i class="fas fa-magnifying-glass"></i> + </span> + </p> + + + {# Filter box tags #} + <div class="card filter-tags"> + <div class="is-flex ml-auto"> + <div id="tag-pool"> + {# A filter tag for when there are no filters active #} + <span class="tag no-tags-selected is-secondary ml-2 mt-2"> + <i class="fas fa-fw fa-ban mr-1"></i> + No filters selected + </span> + + {# A filter tag for search queries #} + <span class="tag search-query is-secondary ml-2 mt-2"> + <i class="fas fa-fw fa-magnifying-glass mr-1"></i> + <span class="tag inner">Search: ...</span> + </span> + + {% for filter_name, filter_data in filters.items %} + {% for filter_item in filter_data.filters %} + {% if filter_name == "Difficulty" %} + <span + class="filter-box-tag tag has-background-info-light has-text-info-dark ml-2 mt-2" + data-filter-name="{{ filter_name|to_kebabcase }}" + data-filter-item="{{ filter_item|to_kebabcase }}" + > + <i class="{{ filter_item|title|get_category_icon }} mr-1"></i> + {{ filter_item|title }} + <button class="delete is-small is-info has-background-info-light"></button> + </span> + {% endif %} + {% if filter_name == "Type" %} + <span + class="filter-box-tag tag has-background-success-light has-text-success-dark ml-2 mt-2" + data-filter-name="{{ filter_name|to_kebabcase }}" + data-filter-item="{{ filter_item|to_kebabcase }}" + > + <i class="{{ filter_item|title|get_category_icon }} mr-1"></i> + {{ filter_item|title }} + <button class="delete is-small is-success has-background-success-light"></button> + </span> + {% endif %} + {% if filter_name == "Payment tiers" %} + <span + class="filter-box-tag tag has-background-danger-light has-text-danger-dark ml-2 mt-2" + data-filter-name="{{ filter_name|to_kebabcase }}" + data-filter-item="{{ filter_item|to_kebabcase }}" + > + <i class="{{ filter_item|title|get_category_icon }} mr-1"></i> + {{ filter_item|title }} + <button class="delete is-small is-danger has-background-danger-light"></button> + </span> + {% endif %} + {% if filter_name == "Topics" %} + <span + class="filter-box-tag tag is-primary is-light ml-2 mt-2" + data-filter-name="{{ filter_name|to_kebabcase }}" + data-filter-item="{{ filter_item|to_kebabcase }}" + > + <i class="{{ filter_item|title|get_category_icon }} mr-1"></i> + {{ filter_item|title }} + <button class="delete is-small is-primary has-background-primary-light"></button> + </span> + {% endif %} + {% endfor %} + {% endfor %} + </div> + <div class="close-filters-button"> + {# A little x in the top right, visible only when filters are active, which removes all filters. #} + <a class="icon"> + <i class="fas fa-window-close"></i> + </a> - <section class="section"> - <div class="container"> - <div class="content"> - <h1>Resources</h1> + </div> + </div> + </div> - <div class="tile is-ancestor"> - <a class="tile is-parent" href="{% url "content:page_category" location="guides" %}"> - <article class="tile is-child box hero is-primary is-bold"> - <p class="title is-size-1"><i class="fad fa-info-circle" aria-hidden="true"></i> Guides</p> - <p class="subtitle is-size-4">Made by us, for you</p> - </article> - </a> + {# Filter checkboxes #} + {% for filter_name, filter_data in filters.items %} + <div class="card filter-category-header"> + <button type="button" class="card-header collapsible"> + <span class="card-header-title subtitle is-6 my-2 ml-2"> + <i class="fa-fw {{ filter_data.icon }} is-primary" aria-hidden="true"></i>  {{ filter_name }} + </span> + <span class="card-header-icon"> + {% if not filter_data.hidden %} + <i class="far fa-fw fa-window-minimize is-6 title" aria-hidden="true"></i> + {% else %} + <i class="fas fa-fw fa-angle-down is-6 title" aria-hidden="true"></i> + {% endif %} + </span> + </button> - <div class="tile is-vertical is-9"> - <div class="tile"> - <a class="tile is-8 is-parent" href="{% url "resources:resources" category="reading" %}"> - <article class="tile is-child box hero is-black" id="readingBlock"> - <p class="title is-size-1"><i class="fad fa-book-alt" aria-hidden="true"></i> Read</p> - <p class="subtitle is-size-4">Lovingly curated books to explore</p> - </article> - </a> + {# Checkboxes #} + {% if filter_data.hidden %} + <div class="collapsible-content collapsed"> + {% else %} + <div class="collapsible-content"> + {% endif %} + <div class="card-content"> + {% for filter_item in filter_data.filters %} + <a class="panel-block filter-panel"> + <label class="checkbox"> + <input + class="filter-checkbox" + type="checkbox" + data-filter-name="{{ filter_name|to_kebabcase }}" + data-filter-item="{{ filter_item|to_kebabcase }}" + > + {{ filter_item }} + </label> + </a> + {% endfor %} + </div> + </div> + </div> + {% endfor %} + </nav> + </div> + </div> - <div class="tile"> - <a class="tile is-parent" href="{% url "resources:resources" category="videos" %}"> - <article class="tile is-child box hero is-danger is-bold"> - <p class="title is-size-1"><i class="fad fa-video" aria-hidden="true"></i> Watch</p> - <p class="subtitle is-size-4">Visually engaging</p> - </article> - </a> - </div> - </div> + <div class="column is-two-thirds"> + {# Message to display when there are no hits #} + <div class="no-resources-found"> + <h2 class="title is-3 has-text-centered pt-0 pb-6">No matching resources found!</h2> + <img src="{% static "images/resources/duck_pond_404.jpg" %}"> + </div> - <div class="tile"> - <a class="tile is-parent" href="{% url "resources:resources" category="interactive" %}"> - <article class="tile is-child box hero is-black" id="interactiveBlock"> - <p class="title is-size-1"><i class="fad fa-code" aria-hidden="true"></i> Try</p> - <p class="subtitle is-size-4">Interactively discover the possibilities</p> - </article> - </a> - <a class="tile is-8 is-parent" href="{% url "resources:resources" category="courses" %}"> - <article class="tile is-child box hero is-success is-bold"> - <p class="title is-size-1"><i class="fad fa-graduation-cap" aria-hidden="true"></i> Learn</p> - <p class="subtitle is-size-4">Structured courses with clear goals</p> - </article> - </a> - </div> - </div> - </div> - <div class="tile is-ancestor"> - <div class="tile is-vertical is-9"> - <div class="tile"> - <a class="tile is-8 is-parent" href="{% url "resources:resources" category="communities" %}"> - <article class="tile is-child box hero is-black" id="communitiesBlock"> - <p class="title is-size-1"><i class="fad fa-users" aria-hidden="true"></i> Communities</p> - <p class="subtitle is-size-4">Some of our best friends</p> - </article> - </a> - <div class="tile"> - <a class="tile is-parent" href="{% url "resources:resources" category="podcasts" %}"> - <article class="tile is-child box hero is-black" id="podcastsBlock"> - <p class="title is-size-1"><i class="fad fa-podcast" aria-hidden="true"></i> Listen</p> - <p class="subtitle is-size-4">Regular podcasts to follow</p> - </article> - </a> - </div> - </div> - </div> - <a class="tile is-parent" href="{% url "resources:resources" category="tools" %}"> - <article class="tile is-child box hero is-dark"> - <p class="title is-size-1"><i class="fad fa-tools" aria-hidden="true"></i> Tools</p> - <p class="subtitle is-size-4">Things we love to use</p> - </article> - </a> - </div> - </div> - </div> - </section> + {# Resource cards #} + <div class="content is-flex is-justify-content-center"> + <div class="container is-fullwidth"> + {% for resource in resources.values %} + {% include "resources/resource_box.html" %} + {% endfor %} + </div> + </div> + </div> + </div> + </section> {% endblock %} diff --git a/pydis_site/templates/resources/resources_list.html b/pydis_site/templates/resources/resources_list.html deleted file mode 100644 index e2be3cb7..00000000 --- a/pydis_site/templates/resources/resources_list.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends "base/base.html" %} -{% load as_icon %} -{% load static %} - -{% block title %}{{ category_info.name }}{% endblock %} -{% block head %} - <link rel="stylesheet" href="{% static "css/resources/resources_list.css" %}"> -{% endblock %} - -{% block content %} - {% include "base/navbar.html" %} - - <section class="section breadcrumb-section"> - <div class="container"> - <nav class="breadcrumb is-pulled-left" aria-label="breadcrumbs"> - <ul> - <li><a href="{% url "resources:index" %}">Resources</a></li> - <li class="is-active"><a href="#">{{ category_info.name }}</a></li> - </ul> - </nav> - </div> - </section> - - <section class="section"> - <div class="container"> - <div class="content"> - <h1>{{ category_info.name }}</h1> - <p>{{ category_info.description|safe }}</p> - <div> - {% for resource in resources|dictsort:"position" %} - {% include "resources/resource_box.html" %} - {% endfor %} - - {% for subcategory in subcategories|dictsort:"category_info.position" %} - <h2 id="{{ subcategory.category_info.raw_name }}"> - <a href="{% url "resources:resources" category=category_info.raw_name %}#{{ subcategory.category_info.raw_name }}"> - {{ subcategory.category_info.name }} - </a> - </h2> - <p>{{ subcategory.category_info.description|safe }}</p> - - {% for resource in subcategory.resources|dictsort:"position" %} - {% with category_info=subcategory.category_info %} - {% include "resources/resource_box.html" %} - {% endwith %} - {% endfor %} - {% endfor %} - </div> - </div> - </div> - </section> -{% endblock %} diff --git a/pydis_site/templates/staff/logs.html b/pydis_site/templates/staff/logs.html index 8c92836a..5e2a200b 100644 --- a/pydis_site/templates/staff/logs.html +++ b/pydis_site/templates/staff/logs.html @@ -14,12 +14,16 @@ <li>Date: {{ deletion_context.creation }}</li> </ul> <div class="is-divider has-small-margin"></div> - {% for message in deletion_context.deletedmessage_set.all %} + {% for message in deletion_context.deletedmessage_set.all reversed %} <div class="discord-message"> <div class="discord-message-header"> <span class="discord-username" - style="color: {{ message.author.top_role.colour | hex_colour }}">{{ message.author }}</span><span - class="discord-message-metadata has-text-grey">{{ message.timestamp }} | User ID: {{ message.author.id }}</span> + style="color: {{ message.author.top_role.colour | hex_colour }}">{{ message.author }} + </span> + <span class="discord-message-metadata has-text-grey"> + User ID: {{ message.author.id }}<br> + {{ message.timestamp }} (Channel ID-Message ID: {{ message.channel_id }}-{{ message.id }}) + </span> </div> <div class="discord-message-content"> {{ message.content | escape | visible_newlines | safe }} diff --git a/pydis_site/utils/resources.py b/pydis_site/utils/resources.py deleted file mode 100644 index 637fd785..00000000 --- a/pydis_site/utils/resources.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import glob -import typing -from dataclasses import dataclass - -import yaml - - -@dataclass -class URL: - """A class representing a link to a resource.""" - - icon: str - title: str - url: str - - -class Resource: - """A class representing a resource on the resource page.""" - - description: str - name: str - payment: str - payment_description: typing.Optional[str] - urls: typing.List[URL] - - def __repr__(self): - """Return a representation of the resource.""" - return f"<Resource name={self.name}>" - - @classmethod - def construct_from_yaml(cls, yaml_data: typing.TextIO) -> Resource: - """Construct a Resource object from the provided YAML.""" - resource = cls() - - loaded = yaml.safe_load(yaml_data) - - resource.__dict__.update(loaded) - - resource.__dict__["urls"] = [] - - for url in loaded["urls"]: - resource.__dict__["urls"].append(URL(**url)) - - return resource - - -class Category: - """A class representing a resource on the resources page.""" - - resources: typing.List[Resource] - name: str - description: str - - def __repr__(self): - """Return a representation of the category.""" - return f"<Category name={self.name}>" - - @classmethod - def construct_from_directory(cls, directory: str) -> Category: - """Construct a Category object from the provided directory.""" - category = cls() - - with open(f"{directory}/_category_info.yaml") as category_info: - category_data = yaml.safe_load(category_info) - - category.__dict__.update(category_data) - - category.resources = [] - - for resource in glob.glob(f"{directory}/*.yaml"): - if resource == f"{directory}/_category_info.yaml": - continue - - with open(resource) as res_file: - category.resources.append( - Resource.construct_from_yaml(res_file) - ) - - return category - - -def load_categories(order: typing.List[str]) -> typing.List[Category]: - """Load the categories specified in the order list and return them.""" - categories = [] - for cat in order: - direc = "pydis_site/apps/home/resources/" + cat - categories.append(Category.construct_from_directory(direc)) - - return categories |