diff options
Diffstat (limited to 'pydis_site')
264 files changed, 10816 insertions, 3511 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/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/__init__.py b/pydis_site/apps/api/__init__.py index afa5b4d5..e69de29b 100644 --- a/pydis_site/apps/api/__init__.py +++ b/pydis_site/apps/api/__init__.py @@ -1 +0,0 @@ -default_app_config = 'pydis_site.apps.api.apps.ApiConfig' diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py index 2aca38a1..f3cc0405 100644 --- a/pydis_site/apps/api/admin.py +++ b/pydis_site/apps/api/admin.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import Iterable, Optional, Tuple +from collections.abc import Iterable from django import urls from django.contrib import admin @@ -13,6 +13,8 @@ from .models import ( BotSetting, DeletedMessage, DocumentationLink, + Filter, + FilterList, Infraction, MessageDeletionContext, Nomination, @@ -60,16 +62,16 @@ class InfractionActorFilter(admin.SimpleListFilter): title = "Actor" parameter_name = "actor" - def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]: + def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[tuple[int, str]]: """Selectable values for viewer to filter by.""" actor_ids = Infraction.objects.order_by().values_list("actor").distinct() actors = User.objects.filter(id__in=actor_ids) return ((a.id, a.username) for a in actors) - def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]: + def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None: """Query to filter the list of Users against.""" if not self.value(): - return + return None return queryset.filter(actor__id=self.value()) @@ -147,7 +149,7 @@ class DeletedMessageAdmin(admin.ModelAdmin): list_display = ("id", "author", "channel_id") - def embed_data(self, message: DeletedMessage) -> Optional[str]: + def embed_data(self, message: DeletedMessage) -> str | None: """Format embed data in a code block for better readability.""" if message.embeds: return format_html( @@ -155,6 +157,7 @@ class DeletedMessageAdmin(admin.ModelAdmin): "<code>{0}</code></pre>", json.dumps(message.embeds, indent=4) ) + return None embed_data.short_description = "Embeds" @@ -194,6 +197,16 @@ class DeletedMessageInline(admin.TabularInline): model = DeletedMessage [email protected](FilterList) +class FilterListAdmin(admin.ModelAdmin): + """Admin formatting for the FilterList model.""" + + [email protected](Filter) +class FilterAdmin(admin.ModelAdmin): + """Admin formatting for the Filter model.""" + + @admin.register(MessageDeletionContext) class MessageDeletionContextAdmin(admin.ModelAdmin): """Admin formatting for the MessageDeletionContext model.""" @@ -217,16 +230,16 @@ class NominationActorFilter(admin.SimpleListFilter): title = "Actor" parameter_name = "actor" - def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]: + def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[tuple[int, str]]: """Selectable values for viewer to filter by.""" actor_ids = NominationEntry.objects.order_by().values_list("actor").distinct() actors = User.objects.filter(id__in=actor_ids) return ((a.id, a.username) for a in actors) - def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]: + def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None: """Query to filter the list of Users against.""" if not self.value(): - return + return None nomination_ids = NominationEntry.objects.filter( actor__id=self.value() ).values_list("nomination_id").distinct() @@ -280,16 +293,16 @@ class NominationEntryActorFilter(admin.SimpleListFilter): title = "Actor" parameter_name = "actor" - def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]: + def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[tuple[int, str]]: """Selectable values for viewer to filter by.""" actor_ids = NominationEntry.objects.order_by().values_list("actor").distinct() actors = User.objects.filter(id__in=actor_ids) return ((a.id, a.username) for a in actors) - def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]: + def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None: """Query to filter the list of Users against.""" if not self.value(): - return + return None return queryset.filter(actor__id=self.value()) @@ -413,15 +426,15 @@ class UserRoleFilter(admin.SimpleListFilter): title = "Role" parameter_name = "role" - def lookups(self, request: HttpRequest, model: UserAdmin) -> Iterable[Tuple[str, str]]: + def lookups(self, request: HttpRequest, model: UserAdmin) -> Iterable[tuple[str, str]]: """Selectable values for viewer to filter by.""" roles = Role.objects.all() return ((r.name, r.name) for r in roles) - def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]: + def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None: """Query to filter the list of Users against.""" if not self.value(): - return + return None role = Role.objects.get(name=self.value()) return queryset.filter(roles__contains=[role.id]) diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py new file mode 100644 index 00000000..b1a7d07d --- /dev/null +++ b/pydis_site/apps/api/github_utils.py @@ -0,0 +1,210 @@ +"""Utilities for working with the GitHub API.""" +import dataclasses +import datetime +import math +import typing + +import httpx +import jwt + +from pydis_site import settings + +MAX_RUN_TIME = datetime.timedelta(minutes=10) +"""The maximum time allowed before an action is declared timed out.""" + + +class ArtifactProcessingError(Exception): + """Base exception for other errors related to processing a GitHub artifact.""" + + status: int + + +class UnauthorizedError(ArtifactProcessingError): + """The application does not have permission to access the requested repo.""" + + status = 401 + + +class NotFoundError(ArtifactProcessingError): + """The requested resource could not be found.""" + + status = 404 + + +class ActionFailedError(ArtifactProcessingError): + """The requested workflow did not conclude successfully.""" + + status = 400 + + +class RunTimeoutError(ArtifactProcessingError): + """The requested workflow run was not ready in time.""" + + status = 408 + + +class RunPendingError(ArtifactProcessingError): + """The requested workflow run is still pending, try again later.""" + + status = 202 + + [email protected](frozen=True) +class WorkflowRun: + """ + A workflow run from the GitHub API. + + https://docs.github.com/en/rest/actions/workflow-runs#get-a-workflow-run + """ + + name: str + head_sha: str + created_at: str + status: str + conclusion: str + artifacts_url: str + + @classmethod + def from_raw(cls, data: dict[str, typing.Any]): + """Create an instance using the raw data from the API, discarding unused fields.""" + return cls(**{ + key.name: data[key.name] for key in dataclasses.fields(cls) + }) + + +def generate_token() -> str: + """ + Generate a JWT token to access the GitHub API. + + The token is valid for roughly 10 minutes after generation, before the API starts + returning 401s. + + Refer to: + https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app + """ + now = datetime.datetime.now(tz=datetime.UTC) + return jwt.encode( + { + "iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()), # Issued at + "exp": math.floor((now + datetime.timedelta(minutes=9)).timestamp()), # Expires at + "iss": settings.GITHUB_APP_ID, + }, + settings.GITHUB_APP_KEY, + algorithm="RS256" + ) + + +def authorize(owner: str, repo: str) -> httpx.Client: + """ + Get an access token for the requested repository. + + The process is roughly: + - GET app/installations to get a list of all app installations + - POST <app_access_token> to get a token to access the given app + - GET installation/repositories and check if the requested one is part of those + """ + client = httpx.Client( + base_url=settings.GITHUB_API, + headers={"Authorization": f"bearer {generate_token()}"}, + timeout=10, + ) + + try: + # Get a list of app installations we have access to + apps = client.get("app/installations") + apps.raise_for_status() + + for app in apps.json(): + # Look for an installation with the right owner + if app["account"]["login"] != owner: + continue + + # Get the repositories of the specified owner + app_token = client.post(app["access_tokens_url"]) + app_token.raise_for_status() + client.headers["Authorization"] = f"bearer {app_token.json()['token']}" + + repos = client.get("installation/repositories") + repos.raise_for_status() + + # Search for the request repository + for accessible_repo in repos.json()["repositories"]: + if accessible_repo["name"] == repo: + # We've found the correct repository, and it's accessible with the current auth + return client + + raise NotFoundError( + "Could not find the requested repository. Make sure the application can access it." + ) + + except BaseException as e: + # Close the client if we encountered an unexpected exception + client.close() + raise e + + +def check_run_status(run: WorkflowRun) -> str: + """Check if the provided run has been completed, otherwise raise an exception.""" + created_at = ( + datetime.datetime + .strptime(run.created_at, settings.GITHUB_TIMESTAMP_FORMAT) + .replace(tzinfo=datetime.UTC) + ) + run_time = datetime.datetime.now(tz=datetime.UTC) - created_at + + if run.status != "completed": + if run_time <= MAX_RUN_TIME: + raise RunPendingError( + f"The requested run is still pending. It was created " + f"{run_time.seconds // 60}:{run_time.seconds % 60 :>02} minutes ago." + ) + raise RunTimeoutError("The requested workflow was not ready in time.") + + if run.conclusion != "success": + # The action failed, or did not run + raise ActionFailedError(f"The requested workflow ended with: {run.conclusion}") + + # The requested action is ready + return run.artifacts_url + + +def get_artifact(owner: str, repo: str, sha: str, action_name: str, artifact_name: str) -> str: + """Get a download URL for a build artifact.""" + client = authorize(owner, repo) + + try: + # Get the workflow runs for this repository + runs = client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100}) + runs.raise_for_status() + runs = runs.json() + + # Filter the runs for the one associated with the given SHA + for run in runs["workflow_runs"]: + run = WorkflowRun.from_raw(run) + if run.name == action_name and sha == run.head_sha: + break + else: + raise NotFoundError( + "Could not find a run matching the provided settings in the previous hundred runs." + ) + + # Check the workflow status + url = check_run_status(run) + + # Filter the artifacts, and return the download URL + artifacts = client.get(url) + artifacts.raise_for_status() + + for artifact in artifacts.json()["artifacts"]: + if artifact["name"] == artifact_name: + data = client.get(artifact["archive_download_url"]) + if data.status_code == 302: + return str(data.next_request.url) + + # The following line is left untested since it should in theory be impossible + data.raise_for_status() # pragma: no cover + + raise NotFoundError("Could not find an artifact matching the provided name.") + + finally: + client.close() diff --git a/pydis_site/apps/api/migrations/0013_specialsnake_image.py b/pydis_site/apps/api/migrations/0013_specialsnake_image.py index a0d0d318..8ba3432f 100644 --- a/pydis_site/apps/api/migrations/0013_specialsnake_image.py +++ b/pydis_site/apps/api/migrations/0013_specialsnake_image.py @@ -2,7 +2,6 @@ import datetime from django.db import migrations, models -from django.utils.timezone import utc class Migration(migrations.Migration): @@ -15,7 +14,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='specialsnake', name='image', - field=models.URLField(default=datetime.datetime(2018, 10, 23, 11, 51, 23, 703868, tzinfo=utc)), + field=models.URLField(default=datetime.datetime(2018, 10, 23, 11, 51, 23, 703868, tzinfo=datetime.timezone.utc)), preserve_default=False, ), ] diff --git a/pydis_site/apps/api/migrations/0019_deletedmessage.py b/pydis_site/apps/api/migrations/0019_deletedmessage.py index 6b848d64..25d04434 100644 --- a/pydis_site/apps/api/migrations/0019_deletedmessage.py +++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py @@ -18,7 +18,7 @@ class Migration(migrations.Migration): ('id', models.BigIntegerField(help_text='The message ID as taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Message IDs cannot be negative.')])), ('channel_id', models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), ('content', models.CharField(help_text='The content of this message, taken from Discord.', max_length=2000)), - ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), help_text='Embeds attached to this message.', size=None)), + ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[]), help_text='Embeds attached to this message.', size=None)), ('author', models.ForeignKey(help_text='The author of this message.', on_delete=django.db.models.deletion.CASCADE, to='api.User')), ('deletion_context', models.ForeignKey(help_text='The deletion context this message is part of.', on_delete=django.db.models.deletion.CASCADE, to='api.MessageDeletionContext')), ], diff --git a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py index 124c6a57..622f21d1 100644 --- a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py +++ b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py @@ -3,7 +3,6 @@ import django.contrib.postgres.fields import django.contrib.postgres.fields.jsonb from django.db import migrations -import pydis_site.apps.api.models.utils class Migration(migrations.Migration): @@ -16,6 +15,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='deletedmessage', name='embeds', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), blank=True, help_text='Embeds attached to this message.', size=None), + field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[]), blank=True, help_text='Embeds attached to this message.', size=None), ), ] diff --git a/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py b/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py index 9e8f2fb9..95ef5850 100644 --- a/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py +++ b/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py @@ -2,7 +2,6 @@ import django.contrib.postgres.fields from django.db import migrations, models -import pydis_site.apps.api.models.utils class Migration(migrations.Migration): @@ -20,6 +19,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='deletedmessage', name='embeds', - field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), blank=True, help_text='Embeds attached to this message.', size=None), + field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(validators=[]), blank=True, help_text='Embeds attached to this message.', size=None), ), ] 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/0084_infraction_last_applied.py b/pydis_site/apps/api/migrations/0084_infraction_last_applied.py new file mode 100644 index 00000000..7704ddb8 --- /dev/null +++ b/pydis_site/apps/api/migrations/0084_infraction_last_applied.py @@ -0,0 +1,26 @@ +# Generated by Django 4.0.6 on 2022-07-27 20:32 + +import django.utils.timezone +from django.db import migrations, models +from django.apps.registry import Apps + + +def set_last_applied_to_inserted_at(apps: Apps, schema_editor): + Infractions = apps.get_model("api", "infraction") + Infractions.objects.all().update(last_applied=models.F("inserted_at")) + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0083_remove_embed_validation'), + ] + + operations = [ + migrations.AddField( + model_name='infraction', + name='last_applied', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='The date and time of when this infraction was last applied.'), + ), + migrations.RunPython(set_last_applied_to_inserted_at) + ] diff --git a/pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py b/pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py new file mode 100644 index 00000000..56a24cc3 --- /dev/null +++ b/pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-11-12 14:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0084_infraction_last_applied'), + ] + + operations = [ + migrations.AddField( + model_name='nomination', + name='thread_id', + field=models.BigIntegerField(help_text="The nomination vote's thread id.", null=True), + ), + ] diff --git a/pydis_site/apps/api/migrations/0086_infraction_jump_url.py b/pydis_site/apps/api/migrations/0086_infraction_jump_url.py new file mode 100644 index 00000000..7ae65751 --- /dev/null +++ b/pydis_site/apps/api/migrations/0086_infraction_jump_url.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.7 on 2023-03-10 17:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0085_add_thread_id_to_nominations'), + ] + + operations = [ + migrations.AddField( + model_name='infraction', + name='jump_url', + field=models.URLField(default=None, help_text='The jump url to message invoking the infraction.', max_length=88, null=True), + ), + ] diff --git a/pydis_site/apps/api/migrations/0087_alter_mute_to_timeout.py b/pydis_site/apps/api/migrations/0087_alter_mute_to_timeout.py new file mode 100644 index 00000000..8a826ba5 --- /dev/null +++ b/pydis_site/apps/api/migrations/0087_alter_mute_to_timeout.py @@ -0,0 +1,25 @@ +from django.apps.registry import Apps +from django.db import migrations, models + +import pydis_site.apps.api.models + + +def rename_type(apps: Apps, _) -> None: + infractions: pydis_site.apps.api.models.Infraction = apps.get_model("api", "Infraction") + infractions.objects.filter(type="mute").update(type="timeout") + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0086_infraction_jump_url'), + ] + + operations = [ + migrations.AlterField( + model_name='infraction', + name='type', + field=models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('timeout', 'Timeout'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban'), ('voice_mute', 'Voice Mute')], help_text='The type of the infraction.', max_length=10), + ), + migrations.RunPython(rename_type, migrations.RunPython.noop) + ] diff --git a/pydis_site/apps/api/migrations/0088_new_filter_schema.py b/pydis_site/apps/api/migrations/0088_new_filter_schema.py new file mode 100644 index 00000000..9bc40779 --- /dev/null +++ b/pydis_site/apps/api/migrations/0088_new_filter_schema.py @@ -0,0 +1,171 @@ +"""Modified migration file to migrate existing filters to the new system.""" +from datetime import timedelta + +import django.contrib.postgres.fields +from django.apps.registry import Apps +from django.core.validators import MinValueValidator +from django.db import migrations, models +import django.db.models.deletion +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import pydis_site.apps.api.models + +OLD_LIST_NAMES = (('GUILD_INVITE', True), ('GUILD_INVITE', False), ('FILE_FORMAT', True), ('DOMAIN_NAME', False), ('FILTER_TOKEN', False), ('REDIRECT', False)) +change_map = { + "FILTER_TOKEN": "token", + "DOMAIN_NAME": "domain", + "GUILD_INVITE": "invite", + "FILE_FORMAT": "extension", + "REDIRECT": "redirect" +} + + +def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: + filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter") + filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList") + filter_list_old = apps.get_model("api", "FilterListOld") + + for name, type_ in OLD_LIST_NAMES: + objects = filter_list_old.objects.filter(type=name, allowed=type_) + if name == "DOMAIN_NAME": + dm_content = "Your message has been removed because it contained a blocked domain: `{domain}`." + elif name == "GUILD_INVITE": + dm_content = "Per Rule 6, your invite link has been removed. " \ + "Our server rules can be found here: https://pythondiscord.com/pages/rules" + else: + dm_content = "" + + list_ = filter_list.objects.create( + name=change_map[name], + list_type=int(type_), + guild_pings=(["Moderators"] if name != "FILE_FORMAT" else []), + filter_dm=True, + dm_pings=[], + remove_context=(True if name != "FILTER_TOKEN" else False), + bypass_roles=["Helpers"], + enabled=True, + dm_content=dm_content, + dm_embed="" if name != "FILE_FORMAT" else "*Defined at runtime.*", + infraction_type="NONE", + infraction_reason="", + infraction_duration=timedelta(seconds=0), + infraction_channel=0, + disabled_channels=[], + disabled_categories=(["CODE JAM"] if name in ("FILE_FORMAT", "GUILD_INVITE") else []), + enabled_channels=[], + enabled_categories=[], + send_alert=(name in ('GUILD_INVITE', 'DOMAIN_NAME', 'FILTER_TOKEN')) + ) + + for object_ in objects: + new_object = filter_.objects.create( + content=object_.content, + created_at=object_.created_at, + updated_at=object_.updated_at, + filter_list=list_, + description=object_.comment, + additional_settings={}, + guild_pings=None, + filter_dm=None, + dm_pings=None, + remove_context=None, + bypass_roles=None, + enabled=None, + dm_content=None, + dm_embed=None, + infraction_type=None, + infraction_reason=None, + infraction_duration=None, + infraction_channel=None, + disabled_channels=None, + disabled_categories=None, + enabled_channels=None, + enabled_categories=None, + send_alert=None, + ) + new_object.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0087_alter_mute_to_timeout'), + ] + + operations = [ + migrations.RenameModel( + old_name='FilterList', + new_name='FilterListOld' + ), + migrations.CreateModel( + name='Filter', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('content', models.TextField(help_text='The definition of this filter.')), + ('description', models.TextField(help_text='Why this filter has been added.', null=True)), + ('additional_settings', models.JSONField(help_text='Additional settings which are specific to this filter.', default=dict)), + ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, null=True)), + ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)), + ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, null=True)), + ('remove_context', models.BooleanField(help_text='Whether this filter should remove the context (such as a message) triggering it.', null=True)), + ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, null=True)), + ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)), + ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True, blank=True)), + ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True, blank=True)), + ('infraction_type', models.CharField(choices=[('NONE', 'None'), ('NOTE', 'Note'), ('WARNING', 'Warning'), ('WATCH', 'Watch'), ('TIMEOUT', 'Timeout'), ('KICK', 'Kick'), ('BAN', 'Ban'), ('SUPERSTAR', 'Superstar'), ('VOICE_BAN', 'Voice Ban'), ('VOICE_MUTE', 'Voice Mute')], help_text='The infraction to apply to this user.', max_length=10, null=True)), + ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True, blank=True)), + ('infraction_duration', models.DurationField(help_text='The duration of the infraction. 0 for permanent.', null=True)), + ('infraction_channel', models.BigIntegerField(validators=(MinValueValidator(limit_value=0, message="Channel IDs cannot be negative."),), help_text="Channel in which to send the infraction.", null=True)), + ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter even if it's enabled in the category.", null=True, size=None)), + ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", null=True, size=None)), + ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", null=True, size=None)), + ('enabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="The only categories in which to run the filter.", null=True, size=None)), + ('send_alert', models.BooleanField(help_text='Whether an alert should be sent.', null=True)), + ], + ), + migrations.CreateModel( + name='FilterList', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(help_text='The unique name of this list.', max_length=50)), + ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')), + ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None)), + ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')), + ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None)), + ('remove_context', models.BooleanField(help_text='Whether this filter should remove the context (such as a message) triggering it.')), + ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None)), + ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')), + ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, blank=True)), + ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, blank=True)), + ('infraction_type', models.CharField(choices=[('NONE', 'None'), ('NOTE', 'Note'), ('WARNING', 'Warning'), ('WATCH', 'Watch'), ('TIMEOUT', 'Timeout'), ('KICK', 'Kick'), ('BAN', 'Ban'), ('SUPERSTAR', 'Superstar'), ('VOICE_BAN', 'Voice Ban'), ('VOICE_MUTE', 'Voice Mute')], help_text='The infraction to apply to this user.', max_length=10)), + ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, blank=True)), + ('infraction_duration', models.DurationField(help_text='The duration of the infraction. 0 for permanent.')), + ('infraction_channel', models.BigIntegerField(validators=(MinValueValidator(limit_value=0, message="Channel IDs cannot be negative."),), help_text="Channel in which to send the infraction.")), + ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter even if it's enabled in the category.", size=None)), + ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", size=None)), + ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", size=None)), + ('enabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="The only categories in which to run the filter.", size=None)), + ('send_alert', models.BooleanField(help_text='Whether an alert should be sent.')), + ], + ), + migrations.AddField( + model_name='filter', + name='filter_list', + field=models.ForeignKey(help_text='The filter list containing this filter.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='api.FilterList'), + ), + migrations.AddConstraint( + model_name='filterlist', + constraint=models.UniqueConstraint(fields=('name', 'list_type'), name='unique_name_type'), + ), + migrations.RunPython( + code=forward, # Core of the migration + reverse_code=lambda *_: None + ), + migrations.DeleteModel( + name='FilterListOld' + ) + ] diff --git a/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py new file mode 100644 index 00000000..cb230a27 --- /dev/null +++ b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0088_new_filter_schema'), + ] + + operations = [ + migrations.RunSQL( + "ALTER TABLE api_filter " + "ADD CONSTRAINT unique_filters UNIQUE NULLS NOT DISTINCT " + "(content, additional_settings, filter_list_id, dm_content, dm_embed, infraction_type, infraction_reason, infraction_duration, infraction_channel, guild_pings, filter_dm, dm_pings, remove_context, bypass_roles, enabled, send_alert, enabled_channels, disabled_channels, enabled_categories, disabled_categories)", + reverse_sql="ALTER TABLE api_filter DROP CONSTRAINT unique_filters", + state_operations=[ + migrations.AddConstraint( + model_name='filter', + constraint=models.UniqueConstraint( + fields=('content', 'additional_settings', 'filter_list', 'dm_content', 'dm_embed', 'infraction_type', 'infraction_reason', 'infraction_duration', 'infraction_channel', 'guild_pings', 'filter_dm', 'dm_pings', 'remove_context', 'bypass_roles', 'enabled', 'send_alert', 'enabled_channels', 'disabled_channels', 'enabled_categories', 'disabled_categories'), + name='unique_filters' + ), + ), + ], + ), + ] diff --git a/pydis_site/apps/api/migrations/0090_unique_filter_list.py b/pydis_site/apps/api/migrations/0090_unique_filter_list.py new file mode 100644 index 00000000..cef2faa3 --- /dev/null +++ b/pydis_site/apps/api/migrations/0090_unique_filter_list.py @@ -0,0 +1,102 @@ +from datetime import timedelta + +from django.apps.registry import Apps +from django.db import migrations + +import pydis_site.apps.api.models.bot.filters + + +def create_unique_list(apps: Apps, _): + """Create the 'unique' FilterList and its related Filters.""" + filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList") + filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter") + + list_ = filter_list.objects.create( + name="unique", + list_type=0, + guild_pings=[], + filter_dm=True, + dm_pings=[], + remove_context=False, + bypass_roles=[], + enabled=True, + dm_content="", + dm_embed="", + infraction_type="NONE", + infraction_reason="", + infraction_duration=timedelta(seconds=0), + infraction_channel=0, + disabled_channels=[], + disabled_categories=[], + enabled_channels=[], + enabled_categories=[], + send_alert=True + ) + + everyone = filter_.objects.create( + content="everyone", + filter_list=list_, + description="", + remove_context=True, + bypass_roles=["Helpers"], + dm_content=( + "Please don't try to ping `@everyone` or `@here`. Your message has been removed. " + "If you believe this was a mistake, please let staff know!" + ), + disabled_categories=["CODE JAM"] + ) + everyone.save() + + webhook = filter_.objects.create( + content="webhook", + filter_list=list_, + description="", + remove_context=True, + dm_content=( + "Looks like you posted a Discord webhook URL. " + "Therefore, your message has been removed, and your webhook has been deleted. " + "You can re-create it if you wish to. " + "If you believe this was a mistake, please let us know." + ), + ) + webhook.save() + + rich_embed = filter_.objects.create( + content="rich_embed", + filter_list=list_, + description="", + guild_pings=["Moderators"], + dm_pings=["Moderators"] + ) + rich_embed.save() + + discord_token = filter_.objects.create( + content="discord_token", + filter_list=list_, + filter_dm=False, + remove_context=True, + dm_content=( + "I noticed you posted a seemingly valid Discord API " + "token in your message and have removed your message. " + "This means that your token has been **compromised**. " + "Please change your token **immediately** at: " + "<https://discord.com/developers/applications>\n\n" + "Feel free to re-post it with the token removed. " + "If you believe this was a mistake, please let us know!" + ) + ) + discord_token.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0089_unique_constraint_filters'), + ] + + operations = [ + migrations.RunPython( + code=create_unique_list, + reverse_code=None + ), + ] diff --git a/pydis_site/apps/api/migrations/0091_antispam_filter_list.py b/pydis_site/apps/api/migrations/0091_antispam_filter_list.py new file mode 100644 index 00000000..7c233142 --- /dev/null +++ b/pydis_site/apps/api/migrations/0091_antispam_filter_list.py @@ -0,0 +1,52 @@ +from datetime import timedelta + +from django.apps.registry import Apps +from django.db import migrations + +import pydis_site.apps.api.models.bot.filters + + +def create_antispam_list(apps: Apps, _): + """Create the 'antispam' FilterList and its related Filters.""" + filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList") + filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter") + + list_ = filter_list.objects.create( + name="antispam", + list_type=0, + guild_pings=["Moderators"], + filter_dm=False, + dm_pings=[], + remove_context=True, + bypass_roles=["Helpers"], + enabled=True, + dm_content="", + dm_embed="", + infraction_type="TIMEOUT", + infraction_reason="", + infraction_duration=timedelta(seconds=600), + infraction_channel=0, + disabled_channels=[], + disabled_categories=["CODE JAM"], + enabled_channels=[], + enabled_categories=[], + send_alert=True + ) + + rules = ("duplicates", "attachments", "burst", "chars", "emoji", "links", "mentions", "newlines", "role_mentions") + + filter_.objects.bulk_create([filter_(content=rule, filter_list=list_) for rule in rules]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0090_unique_filter_list'), + ] + + operations = [ + migrations.RunPython( + code=create_antispam_list, + reverse_code=None + ), + ] diff --git a/pydis_site/apps/api/migrations/0092_remove_redirect_filter_list.py b/pydis_site/apps/api/migrations/0092_remove_redirect_filter_list.py new file mode 100644 index 00000000..69dd99d0 --- /dev/null +++ b/pydis_site/apps/api/migrations/0092_remove_redirect_filter_list.py @@ -0,0 +1,16 @@ +from django.db import migrations +from django.apps.registry import Apps +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def forward(apps: Apps, _: BaseDatabaseSchemaEditor) -> None: + apps.get_model("api", "FilterList").objects.filter(name="redirect").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0091_antispam_filter_list"), + ] + + operations = [migrations.RunPython(forward)] diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index fd5bf220..fee4c8d5 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -1,7 +1,9 @@ # flake8: noqa from .bot import ( FilterList, + Filter, BotSetting, + BumpedThread, DocumentationLink, DeletedMessage, Infraction, @@ -10,6 +12,8 @@ from .bot import ( Nomination, NominationEntry, OffensiveMessage, + AocAccountLink, + AocCompletionistBlock, OffTopicChannelName, Reminder, Role, diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index ac864de3..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 .filter_list import FilterList +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/documentation_link.py b/pydis_site/apps/api/models/bot/documentation_link.py index 9941907c..7f3b4ca5 100644 --- a/pydis_site/apps/api/models/bot/documentation_link.py +++ b/pydis_site/apps/api/models/bot/documentation_link.py @@ -37,11 +37,11 @@ class DocumentationLink(ModelReprMixin, models.Model): help_text="The URL at which the Sphinx inventory is available for this package." ) - def __str__(self): - """Returns the package and URL for the current documentation link, for display purposes.""" - return f"{self.package} - {self.base_url}" - class Meta: """Defines the meta options for the documentation link model.""" ordering = ['package'] + + def __str__(self): + """Returns the package and URL for the current documentation link, for display purposes.""" + return f"{self.package} - {self.base_url}" diff --git a/pydis_site/apps/api/models/bot/filter_list.py b/pydis_site/apps/api/models/bot/filter_list.py deleted file mode 100644 index d30f7213..00000000 --- a/pydis_site/apps/api/models/bot/filter_list.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.db import models - -from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin - - -class FilterList(ModelTimestampMixin, ModelReprMixin, models.Model): - """An item that is either allowed or denied.""" - - FilterListType = models.TextChoices( - 'FilterListType', - 'GUILD_INVITE ' - 'FILE_FORMAT ' - 'DOMAIN_NAME ' - 'FILTER_TOKEN ' - 'REDIRECT ' - ) - type = models.CharField( - max_length=50, - help_text="The type of allowlist this is on.", - choices=FilterListType.choices, - ) - allowed = models.BooleanField( - help_text="Whether this item is on the allowlist or the denylist." - ) - content = models.TextField( - help_text="The data to add to the allow or denylist." - ) - comment = models.TextField( - help_text="Optional comment on this entry.", - null=True - ) - - class Meta: - """Metaconfig for this model.""" - - # This constraint ensures only one filterlist with the - # same content can exist. This means that we cannot have both an allow - # and a deny for the same item, and we cannot have duplicates of the - # same item. - constraints = [ - models.UniqueConstraint(fields=['content', 'type'], name='unique_filter_list'), - ] diff --git a/pydis_site/apps/api/models/bot/filters.py b/pydis_site/apps/api/models/bot/filters.py new file mode 100644 index 00000000..6d5188e4 --- /dev/null +++ b/pydis_site/apps/api/models/bot/filters.py @@ -0,0 +1,261 @@ +from django.contrib.postgres.fields import ArrayField +from django.core.validators import MinValueValidator +from django.db import models +from django.db.models import UniqueConstraint + +# Must be imported that way to avoid circular imports +from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin +from .infraction import Infraction + + +class FilterListType(models.IntegerChoices): + """Choice between allow or deny for a list type.""" + + ALLOW = 1 + DENY = 0 + + +class FilterList(ModelTimestampMixin, ModelReprMixin, models.Model): + """Represent a list in its allow or deny form.""" + + name = models.CharField(max_length=50, help_text="The unique name of this list.") + list_type = models.IntegerField( + choices=FilterListType.choices, + help_text="Whether this list is an allowlist or denylist" + ) + dm_content = models.CharField( + max_length=1000, + null=False, + blank=True, + help_text="The DM to send to a user triggering this filter." + ) + dm_embed = models.CharField( + max_length=2000, + help_text="The content of the DM embed", + null=False, + blank=True + ) + infraction_type = models.CharField( + choices=[ + (choices[0].upper(), choices[1]) + for choices in [("NONE", "None"), *Infraction.TYPE_CHOICES] + ], + max_length=10, + null=False, + help_text="The infraction to apply to this user." + ) + infraction_reason = models.CharField( + max_length=1000, + help_text="The reason to give for the infraction.", + blank=True, + null=False + ) + infraction_duration = models.DurationField( + null=False, + help_text="The duration of the infraction. 0 for permanent." + ) + infraction_channel = models.BigIntegerField( + validators=( + MinValueValidator( + limit_value=0, + message="Channel IDs cannot be negative." + ), + ), + help_text="Channel in which to send the infraction.", + null=False + ) + guild_pings = ArrayField( + models.CharField(max_length=100), + help_text="Who to ping when this filter triggers.", + null=False + ) + filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False) + dm_pings = ArrayField( + models.CharField(max_length=100), + help_text="Who to ping when this filter triggers on a DM.", + null=False + ) + remove_context = models.BooleanField( + help_text=( + "Whether this filter should remove the context (such as a message) " + "triggering it." + ), + null=False + ) + bypass_roles = ArrayField( + models.CharField(max_length=100), + help_text="Roles and users who can bypass this filter.", + null=False + ) + enabled = models.BooleanField( + help_text="Whether this filter is currently enabled.", + null=False + ) + send_alert = models.BooleanField( + help_text="Whether an alert should be sent.", + ) + # Where a filter should apply. + enabled_channels = ArrayField( + models.CharField(max_length=100), + help_text="Channels in which to run the filter even if it's disabled in the category." + ) + disabled_channels = ArrayField( + models.CharField(max_length=100), + help_text="Channels in which to not run the filter even if it's enabled in the category." + ) + enabled_categories = ArrayField( + models.CharField(max_length=100), + help_text="The only categories in which to run the filter." + ) + disabled_categories = ArrayField( + models.CharField(max_length=100), + help_text="Categories in which to not run the filter." + ) + + class Meta: + """Constrain name and list_type unique.""" + + constraints = ( + UniqueConstraint(fields=("name", "list_type"), name="unique_name_type"), + ) + + def __str__(self) -> str: + return f"Filter {FilterListType(self.list_type).label}list {self.name!r}" + + +class FilterBase(ModelTimestampMixin, ModelReprMixin, models.Model): + """One specific trigger of a list.""" + + content = models.TextField(help_text="The definition of this filter.") + description = models.TextField( + help_text="Why this filter has been added.", null=True + ) + additional_settings = models.JSONField( + help_text="Additional settings which are specific to this filter.", default=dict + ) + filter_list = models.ForeignKey( + FilterList, models.CASCADE, related_name="filters", + help_text="The filter list containing this filter." + ) + dm_content = models.CharField( + max_length=1000, + null=True, + blank=True, + help_text="The DM to send to a user triggering this filter." + ) + dm_embed = models.CharField( + max_length=2000, + help_text="The content of the DM embed", + null=True, + blank=True + ) + infraction_type = models.CharField( + choices=[ + (choices[0].upper(), choices[1]) + for choices in [("NONE", "None"), *Infraction.TYPE_CHOICES] + ], + max_length=10, + null=True, + help_text="The infraction to apply to this user." + ) + infraction_reason = models.CharField( + max_length=1000, + help_text="The reason to give for the infraction.", + blank=True, + null=True + ) + infraction_duration = models.DurationField( + null=True, + help_text="The duration of the infraction. 0 for permanent." + ) + infraction_channel = models.BigIntegerField( + validators=( + MinValueValidator( + limit_value=0, + message="Channel IDs cannot be negative." + ), + ), + help_text="Channel in which to send the infraction.", + null=True + ) + guild_pings = ArrayField( + models.CharField(max_length=100), + help_text="Who to ping when this filter triggers.", + null=True + ) + filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True) + dm_pings = ArrayField( + models.CharField(max_length=100), + help_text="Who to ping when this filter triggers on a DM.", + null=True + ) + remove_context = models.BooleanField( + help_text=( + "Whether this filter should remove the context (such as a message) " + "triggering it." + ), + null=True + ) + bypass_roles = ArrayField( + models.CharField(max_length=100), + help_text="Roles and users who can bypass this filter.", + null=True + ) + enabled = models.BooleanField( + help_text="Whether this filter is currently enabled.", + null=True + ) + send_alert = models.BooleanField( + help_text="Whether an alert should be sent.", + null=True + ) + + enabled_channels = ArrayField( + models.CharField(max_length=100), + help_text="Channels in which to run the filter even if it's disabled in the category.", + null=True + ) + disabled_channels = ArrayField( + models.CharField(max_length=100), + help_text="Channels in which to not run the filter even if it's enabled in the category.", + null=True + ) + enabled_categories = ArrayField( + models.CharField(max_length=100), + help_text="The only categories in which to run the filter.", + null=True + ) + disabled_categories = ArrayField( + models.CharField(max_length=100), + help_text="Categories in which to not run the filter.", + null=True + ) + + class Meta: + """Metaclass for FilterBase to make it abstract model.""" + + abstract = True + + def __str__(self) -> str: + return f"Filter {self.content!r}" + + +class Filter(FilterBase): + """ + The main Filter models based on `FilterBase`. + + The purpose to have this model is to have access to the Fields of the Filter model + and set the unique constraint based on those fields. + """ + + class Meta: + """Metaclass Filter to set the unique constraint.""" + + constraints = ( + UniqueConstraint( + fields=tuple( + field.name for field in FilterBase._meta.fields + if field.name not in ("id", "description", "created_at", "updated_at") + ), + name="unique_filters"), + ) diff --git a/pydis_site/apps/api/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py index c9303024..b304c6d4 100644 --- a/pydis_site/apps/api/models/bot/infraction.py +++ b/pydis_site/apps/api/models/bot/infraction.py @@ -12,7 +12,7 @@ class Infraction(ModelReprMixin, models.Model): ("note", "Note"), ("warning", "Warning"), ("watch", "Watch"), - ("mute", "Mute"), + ("timeout", "Timeout"), ("kick", "Kick"), ("ban", "Ban"), ("superstar", "Superstar"), @@ -23,6 +23,12 @@ class Infraction(ModelReprMixin, models.Model): default=timezone.now, help_text="The date and time of the creation of this infraction." ) + last_applied = models.DateTimeField( + # This default is for backwards compatibility with bot versions + # that don't explicitly give a value. + default=timezone.now, + help_text="The date and time of when this infraction was last applied." + ) expires_at = models.DateTimeField( null=True, help_text=( @@ -63,14 +69,14 @@ class Infraction(ModelReprMixin, models.Model): help_text="Whether a DM was sent to the user when infraction was applied." ) - def __str__(self): - """Returns some info on the current infraction, for display purposes.""" - s = f"#{self.id}: {self.type} on {self.user_id}" - if self.expires_at: - s += f" until {self.expires_at}" - if self.hidden: - s += " (hidden)" - return s + jump_url = models.URLField( + default=None, + null=True, + max_length=88, + help_text=( + "The jump url to message invoking the infraction." + ) + ) class Meta: """Defines the meta options for the infraction model.""" @@ -83,3 +89,12 @@ class Infraction(ModelReprMixin, models.Model): name="unique_active_infraction_per_type_per_user" ), ) + + def __str__(self): + """Returns some info on the current infraction, for display purposes.""" + s = f"#{self.id}: {self.type} on {self.user_id}" + if self.expires_at: + s += f" until {self.expires_at}" + if self.hidden: + s += " (hidden)" + return s diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index bab3368d..f90f5dd0 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -1,13 +1,11 @@ -from datetime import datetime +import datetime from django.contrib.postgres import fields as pgfields from django.core.validators import MinValueValidator from django.db import models -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 +46,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." ) @@ -62,14 +58,15 @@ class Message(ModelReprMixin, models.Model): help_text="Attachments attached to this message." ) - @property - def timestamp(self) -> datetime: - """Attribute that represents the message timestamp as derived from the snowflake id.""" - tz_naive_datetime = datetime.utcfromtimestamp(((self.id >> 22) + 1420070400000) / 1000) - tz_aware_datetime = timezone.make_aware(tz_naive_datetime, timezone=timezone.utc) - return tz_aware_datetime - class Meta: """Metadata provided for Django's ORM.""" abstract = True + + @property + def timestamp(self) -> datetime.datetime: + """Attribute that represents the message timestamp as derived from the snowflake id.""" + return datetime.datetime.fromtimestamp( + ((self.id >> 22) + 1420070400000) / 1000, + tz=datetime.UTC, + ) diff --git a/pydis_site/apps/api/models/bot/message_deletion_context.py b/pydis_site/apps/api/models/bot/message_deletion_context.py index 25741266..207bc4bc 100644 --- a/pydis_site/apps/api/models/bot/message_deletion_context.py +++ b/pydis_site/apps/api/models/bot/message_deletion_context.py @@ -30,12 +30,12 @@ class MessageDeletionContext(ModelReprMixin, models.Model): help_text="When this deletion took place." ) - @property - def log_url(self) -> str: - """Create the url for the deleted message logs.""" - return reverse('staff:logs', args=(self.id,)) - class Meta: """Set the ordering for list views to newest first.""" ordering = ("-creation",) + + @property + def log_url(self) -> str: + """Create the url for the deleted message logs.""" + return reverse('staff:logs', args=(self.id,)) diff --git a/pydis_site/apps/api/models/bot/metricity.py b/pydis_site/apps/api/models/bot/metricity.py index abd25ef0..f1277b21 100644 --- a/pydis_site/apps/api/models/bot/metricity.py +++ b/pydis_site/apps/api/models/bot/metricity.py @@ -1,19 +1,18 @@ -from typing import List, Tuple from django.db import connections BLOCK_INTERVAL = 10 * 60 # 10 minute blocks -EXCLUDE_CHANNELS = ( +# This needs to be a list due to psycopg3 type adaptions. +EXCLUDE_CHANNELS = [ "267659945086812160", # Bot commands "607247579608121354" # SeasonalBot commands -) +] -class NotFoundError(Exception): # noqa: N818 +class NotFoundError(Exception): """Raised when an entity cannot be found.""" - pass class Metricity: @@ -31,15 +30,14 @@ class Metricity: def user(self, user_id: str) -> dict: """Query a user's data.""" # TODO: Swap this back to some sort of verified at date - columns = ["joined_at"] - query = f"SELECT {','.join(columns)} FROM users WHERE id = '%s'" + query = "SELECT joined_at FROM users WHERE id = '%s'" self.cursor.execute(query, [user_id]) values = self.cursor.fetchone() if not values: - raise NotFoundError() + raise NotFoundError - return dict(zip(columns, values)) + return {'joined_at': values[0]} def total_messages(self, user_id: str) -> int: """Query total number of messages for a user.""" @@ -51,14 +49,14 @@ class Metricity: WHERE author_id = '%s' AND NOT is_deleted - AND channel_id NOT IN %s + AND channel_id != ANY(%s) """, [user_id, EXCLUDE_CHANNELS] ) values = self.cursor.fetchone() if not values: - raise NotFoundError() + raise NotFoundError return values[0] @@ -79,7 +77,7 @@ class Metricity: WHERE author_id='%s' AND NOT is_deleted - AND channel_id NOT IN %s + AND channel_id != ANY(%s) GROUP BY interval ) block_query; """, @@ -88,11 +86,11 @@ class Metricity: values = self.cursor.fetchone() if not values: - raise NotFoundError() + raise NotFoundError return values[0] - def top_channel_activity(self, user_id: str) -> List[Tuple[str, int]]: + def top_channel_activity(self, user_id: str) -> list[tuple[str, int]]: """ Query the top three channels in which the user is most active. @@ -127,6 +125,34 @@ class Metricity: values = self.cursor.fetchall() if not values: - raise NotFoundError() + raise NotFoundError + + return values + + def total_messages_in_past_n_days( + self, + user_ids: list[str], + days: int + ) -> list[tuple[str, int]]: + """ + Query activity by a list of users in the past `days` days. + + Returns a list of (user_id, message_count) tuples. + """ + self.cursor.execute( + """ + SELECT + author_id, COUNT(*) + FROM messages + WHERE + author_id = ANY(%s) + AND NOT is_deleted + AND channel_id != ANY(%s) + AND created_at > now() - interval '%s days' + GROUP BY author_id + """, + [user_ids, EXCLUDE_CHANNELS, days] + ) + values = self.cursor.fetchall() return values diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py index 221d8534..2f8e305c 100644 --- a/pydis_site/apps/api/models/bot/nomination.py +++ b/pydis_site/apps/api/models/bot/nomination.py @@ -35,17 +35,21 @@ class Nomination(ModelReprMixin, models.Model): default=False, help_text="Whether a review was made." ) - - def __str__(self): - """Representation that makes the target and state of the nomination immediately evident.""" - status = "active" if self.active else "ended" - return f"Nomination of {self.user} ({status})" + thread_id = models.BigIntegerField( + help_text="The nomination vote's thread id.", + null=True, + ) class Meta: """Set the ordering of nominations to most recent first.""" ordering = ("-inserted_at",) + def __str__(self): + """Representation that makes the target and state of the nomination immediately evident.""" + status = "active" if self.active else "ended" + return f"Nomination of {self.user} ({status})" + class NominationEntry(ModelReprMixin, models.Model): """A nomination entry created by a single staff member.""" diff --git a/pydis_site/apps/api/models/bot/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/bot/offensive_message.py b/pydis_site/apps/api/models/bot/offensive_message.py index 74dab59b..41805a16 100644 --- a/pydis_site/apps/api/models/bot/offensive_message.py +++ b/pydis_site/apps/api/models/bot/offensive_message.py @@ -9,7 +9,7 @@ from pydis_site.apps.api.models.mixins import ModelReprMixin def future_date_validator(date: datetime.date) -> None: """Raise ValidationError if the date isn't a future date.""" - if date < datetime.datetime.now(datetime.timezone.utc): + if date < datetime.datetime.now(datetime.UTC): raise ValidationError("Date must be a future date") diff --git a/pydis_site/apps/api/models/bot/role.py b/pydis_site/apps/api/models/bot/role.py index 733a8e08..e37f3ccd 100644 --- a/pydis_site/apps/api/models/bot/role.py +++ b/pydis_site/apps/api/models/bot/role.py @@ -51,6 +51,11 @@ class Role(ModelReprMixin, models.Model): help_text="The position of the role in the role hierarchy of the Discord Guild." ) + class Meta: + """Set role ordering from highest to lowest position.""" + + ordering = ("-position",) + def __str__(self) -> str: """Returns the name of the current role, for display purposes.""" return self.name @@ -62,8 +67,3 @@ class Role(ModelReprMixin, models.Model): def __le__(self, other: Role) -> bool: """Compares the roles based on their position in the role hierarchy of the guild.""" return self.position <= other.position - - class Meta: - """Set role ordering from highest to lowest position.""" - - ordering = ("-position",) diff --git a/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 4a702d61..87fd6190 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -1,4 +1,8 @@ """Converters from Django models to data interchange formats and back.""" +from datetime import timedelta +from typing import Any + +from django.db import models from django.db.models.query import QuerySet from django.db.utils import IntegrityError from rest_framework.exceptions import NotFound @@ -13,9 +17,13 @@ from rest_framework.settings import api_settings from rest_framework.validators import UniqueTogetherValidator from .models import ( + AocAccountLink, + AocCompletionistBlock, BotSetting, + BumpedThread, DeletedMessage, DocumentationLink, + Filter, FilterList, Infraction, MessageDeletionContext, @@ -28,6 +36,30 @@ from .models import ( User ) +class FrozenFieldsMixin: + """ + Serializer mixin that allows adding non-updateable fields to a serializer. + + To use, inherit from the mixin and specify the fields that should only be + written to on creation in the `frozen_fields` attribute of the `Meta` class + in a serializer. + + See also the DRF discussion for this feature at + https://github.com/encode/django-rest-framework/discussions/8606, which may + eventually provide an official way to implement this. + """ + + def update(self, instance: models.Model, validated_data: dict) -> models.Model: + """Validate that no frozen fields were changed and update the instance.""" + for field_name in getattr(self.Meta, 'frozen_fields', ()): + if field_name in validated_data: + raise ValidationError( + { + field_name: ["This field cannot be updated."] + } + ) + return super().update(instance, validated_data) + class BotSettingSerializer(ModelSerializer): """A class providing (de-)serialization of `BotSetting` instances.""" @@ -39,6 +71,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. @@ -112,32 +170,288 @@ class DocumentationLinkSerializer(ModelSerializer): fields = ('package', 'base_url', 'inventory_url') +# region: filters serializers + +SETTINGS_FIELDS = ( + 'dm_content', + 'dm_embed', + 'infraction_type', + 'infraction_reason', + 'infraction_duration', + 'infraction_channel', + 'guild_pings', + 'filter_dm', + 'dm_pings', + 'remove_context', + 'send_alert', + 'bypass_roles', + 'enabled', + 'enabled_channels', + 'disabled_channels', + 'enabled_categories', + 'disabled_categories', +) + +ALLOW_BLANK_SETTINGS = ( + 'dm_content', + 'dm_embed', + 'infraction_reason', +) + +ALLOW_EMPTY_SETTINGS = ( + 'enabled_channels', + 'disabled_channels', + 'enabled_categories', + 'disabled_categories', + 'guild_pings', + 'dm_pings', + 'bypass_roles', +) + +# Required fields for custom JSON representation purposes +BASE_FILTER_FIELDS = ( + 'id', 'created_at', 'updated_at', 'content', 'description', 'additional_settings' +) +BASE_FILTERLIST_FIELDS = ('id', 'created_at', 'updated_at', 'name', 'list_type') +BASE_SETTINGS_FIELDS = ( + 'bypass_roles', + 'filter_dm', + 'enabled', + 'remove_context', + 'send_alert' +) +INFRACTION_AND_NOTIFICATION_FIELDS = ( + 'infraction_type', + 'infraction_reason', + 'infraction_duration', + 'infraction_channel', + 'dm_content', + 'dm_embed' +) +CHANNEL_SCOPE_FIELDS = ( + 'disabled_channels', + 'disabled_categories', + 'enabled_channels', + 'enabled_categories' +) +MENTIONS_FIELDS = ('guild_pings', 'dm_pings') + +MAX_TIMEOUT_DURATION = timedelta(days=28) + + +def _create_meta_extra_kwargs(*, for_filter: bool) -> dict[str, dict[str, bool]]: + """Create the extra kwargs for the Meta classes of the Filter and FilterList serializers.""" + extra_kwargs = {} + for field in SETTINGS_FIELDS: + field_args = {'required': False, 'allow_null': True} if for_filter else {} + if field in ALLOW_BLANK_SETTINGS: + field_args['allow_blank'] = True + if field in ALLOW_EMPTY_SETTINGS: + field_args['allow_empty'] = True + extra_kwargs[field] = field_args + return extra_kwargs + + +def get_field_value(data: dict, field_name: str) -> Any: + """Get the value directly from the key, or from the filter list if it's missing or is None.""" + if data.get(field_name) is not None: + return data[field_name] + return getattr(data['filter_list'], field_name) + + +class FilterSerializer(ModelSerializer): + """A class providing (de-)serialization of `Filter` instances.""" + + def validate(self, data: dict) -> dict: + """Perform infraction data + allowed and disallowed lists validation.""" + infraction_type = get_field_value(data, 'infraction_type') + infraction_duration = get_field_value(data, 'infraction_duration') + if ( + (get_field_value(data, 'infraction_reason') or infraction_duration) + and infraction_type == 'NONE' + ): + raise ValidationError( + "Infraction type is required with infraction duration or reason." + ) + + if ( + infraction_type == 'TIMEOUT' + and (not infraction_duration or infraction_duration > MAX_TIMEOUT_DURATION) + ): + raise ValidationError( + f"A timeout cannot be longer than {MAX_TIMEOUT_DURATION.days} days." + ) + + common_channels = ( + set(get_field_value(data, 'disabled_channels')) + & set(get_field_value(data, 'enabled_channels')) + ) + if common_channels: + raise ValidationError( + "You can't have the same value in both enabled and disabled channels lists:" + f" {', '.join(repr(channel) for channel in common_channels)}." + ) + + common_categories = ( + set(get_field_value(data, 'disabled_categories')) + & set(get_field_value(data, 'enabled_categories')) + ) + if common_categories: + raise ValidationError( + "You can't have the same value in both enabled and disabled categories lists:" + f" {', '.join(repr(category) for category in common_categories)}." + ) + + return data + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = Filter + fields = ( + 'id', + 'created_at', + 'updated_at', + 'content', + 'description', + 'additional_settings', + 'filter_list' + ) + SETTINGS_FIELDS + extra_kwargs = _create_meta_extra_kwargs(for_filter=True) + + def create(self, validated_data: dict) -> User: + """Override the create method to catch violations of the custom uniqueness constraint.""" + try: + return super().create(validated_data) + except IntegrityError: + raise ValidationError( + "Check if a filter with this combination of content " + "and settings already exists in this filter list." + ) + + def to_representation(self, instance: Filter) -> dict: + """ + Provides a custom JSON representation to the Filter Serializers. + + This representation restructures how the Filter is represented. + It groups the Infraction, Channel and Mention related fields into their own separated group. + + Furthermore, it puts the fields that meant to represent Filter settings, + into a sub-field called `settings`. + """ + settings = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} + settings['infraction_and_notification'] = { + name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS + } + settings['channel_scope'] = { + name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS + } + settings['mentions'] = { + name: getattr(instance, name) for name in MENTIONS_FIELDS + } + + schema = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS} + schema['filter_list'] = instance.filter_list.id + schema['settings'] = settings + return schema + + class FilterListSerializer(ModelSerializer): """A class providing (de-)serialization of `FilterList` instances.""" + filters = FilterSerializer(many=True, read_only=True) + + def validate(self, data: dict) -> dict: + """Perform infraction data + allow and disallowed lists validation.""" + infraction_duration = data.get('infraction_duration') + if ( + data.get('infraction_reason') or infraction_duration + ) and not data.get('infraction_type'): + raise ValidationError("Infraction type is required with infraction duration or reason") + + if ( + data.get('disabled_channels') is not None + and data.get('enabled_channels') is not None + ): + common_channels = set(data['disabled_channels']) & set(data['enabled_channels']) + if common_channels: + raise ValidationError( + "You can't have the same value in both enabled and disabled channels lists:" + f" {', '.join(repr(channel) for channel in common_channels)}." + ) + + if ( + data.get('infraction_type') == 'TIMEOUT' + and (not infraction_duration or infraction_duration > MAX_TIMEOUT_DURATION) + ): + raise ValidationError( + f"A timeout cannot be longer than {MAX_TIMEOUT_DURATION.days} days." + ) + + if ( + data.get('disabled_categories') is not None + and data.get('enabled_categories') is not None + ): + common_categories = set(data['disabled_categories']) & set(data['enabled_categories']) + if common_categories: + raise ValidationError( + "You can't have the same value in both enabled and disabled categories lists:" + f" {', '.join(repr(category) for category in common_categories)}." + ) + + return data + class Meta: """Metadata defined for the Django REST Framework.""" model = FilterList - fields = ('id', 'created_at', 'updated_at', 'type', 'allowed', 'content', 'comment') + fields = ( + 'id', 'created_at', 'updated_at', 'name', 'list_type', 'filters' + ) + SETTINGS_FIELDS + extra_kwargs = _create_meta_extra_kwargs(for_filter=False) - # This validator ensures only one filterlist with the - # same content can exist. This means that we cannot have both an allow - # and a deny for the same item, and we cannot have duplicates of the - # same item. + # Ensure there can only be one filter list with the same name and type. validators = [ UniqueTogetherValidator( queryset=FilterList.objects.all(), - fields=['content', 'type'], + fields=('name', 'list_type'), message=( - "A filterlist for this item already exists. " - "Please note that you cannot add the same item to both allow and deny." + "A filterlist with the same name and type already exists." ) ), ] + def to_representation(self, instance: FilterList) -> dict: + """ + Provides a custom JSON representation to the FilterList Serializers. + + This representation restructures how the Filter is represented. + It groups the Infraction, Channel, and Mention related fields + into their own separated groups. + + Furthermore, it puts the fields that are meant to represent FilterList settings, + into a sub-field called `settings`. + """ + schema = {name: getattr(instance, name) for name in BASE_FILTERLIST_FIELDS} + schema['filters'] = [ + FilterSerializer(many=False).to_representation(instance=item) + for item in Filter.objects.filter(filter_list=instance.id) + ] + + settings = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} + settings['infraction_and_notification'] = { + name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS + } + settings['channel_scope'] = {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS} + settings['mentions'] = {name: getattr(instance, name) for name in MENTIONS_FIELDS} + + schema['settings'] = settings + return schema + +# endregion + -class InfractionSerializer(ModelSerializer): +class InfractionSerializer(FrozenFieldsMixin, ModelSerializer): """A class providing (de-)serialization of `Infraction` instances.""" class Meta: @@ -147,6 +461,7 @@ class InfractionSerializer(ModelSerializer): fields = ( 'id', 'inserted_at', + 'last_applied', 'expires_at', 'active', 'user', @@ -154,15 +469,10 @@ class InfractionSerializer(ModelSerializer): 'type', 'reason', 'hidden', - 'dm_sent' + 'dm_sent', + 'jump_url' ) - validators = [ - UniqueTogetherValidator( - queryset=Infraction.objects.filter(active=True), - fields=['user', 'type', 'active'], - message='This user already has an active infraction of this type.', - ) - ] + frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden') def validate(self, attrs: dict) -> dict: """Validate data constraints for the given data and abort if it is invalid.""" @@ -180,7 +490,7 @@ class InfractionSerializer(ModelSerializer): if hidden and infr_type in ('superstar', 'warning', 'voice_ban', 'voice_mute'): raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']}) - if not hidden and infr_type in ('note', ): + if not hidden and infr_type in ('note',): raise ValidationError({'hidden': [f'{infr_type} infractions must be hidden.']}) return attrs @@ -257,6 +567,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.""" @@ -379,7 +709,7 @@ class NominationEntrySerializer(ModelSerializer): fields = ('nomination', 'actor', 'reason', 'inserted_at') -class NominationSerializer(ModelSerializer): +class NominationSerializer(FrozenFieldsMixin, ModelSerializer): """A class providing (de-)serialization of `Nomination` instances.""" entries = NominationEntrySerializer(many=True, read_only=True) @@ -389,11 +719,20 @@ class NominationSerializer(ModelSerializer): model = Nomination fields = ( - 'id', 'active', 'user', 'inserted_at', 'end_reason', 'ended_at', 'reviewed', 'entries' + 'id', + 'active', + 'user', + 'inserted_at', + 'end_reason', + 'ended_at', + 'reviewed', + 'entries', + 'thread_id' ) + frozen_fields = ('id', 'inserted_at', 'user', 'ended_at') -class OffensiveMessageSerializer(ModelSerializer): +class OffensiveMessageSerializer(FrozenFieldsMixin, ModelSerializer): """A class providing (de-)serialization of `OffensiveMessage` instances.""" class Meta: @@ -401,3 +740,4 @@ class OffensiveMessageSerializer(ModelSerializer): model = OffensiveMessage fields = ('id', 'channel_id', 'delete_date') + frozen_fields = ('id', 'channel_id') diff --git a/pydis_site/apps/api/tests/base.py b/pydis_site/apps/api/tests/base.py index c9f3cb7e..704b22cf 100644 --- a/pydis_site/apps/api/tests/base.py +++ b/pydis_site/apps/api/tests/base.py @@ -61,6 +61,7 @@ class AuthenticatedAPITestCase(APITestCase): ... self.assertEqual(response.status_code, 200) """ - def setUp(self): + def setUp(self) -> None: + """Bootstrap the user and authenticate it.""" super().setUp() self.client.force_authenticate(test_user) diff --git a/pydis_site/apps/api/tests/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..2e3892c7 --- /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 pydis_site.apps.api.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_deleted_messages.py b/pydis_site/apps/api/tests/test_deleted_messages.py index 1eb535d8..d5501202 100644 --- a/pydis_site/apps/api/tests/test_deleted_messages.py +++ b/pydis_site/apps/api/tests/test_deleted_messages.py @@ -1,10 +1,9 @@ -from datetime import datetime +from datetime import UTC, datetime from django.urls import reverse -from django.utils import timezone from .base import AuthenticatedAPITestCase -from ..models import MessageDeletionContext, User +from pydis_site.apps.api.models import MessageDeletionContext, User class DeletedMessagesWithoutActorTests(AuthenticatedAPITestCase): @@ -18,7 +17,7 @@ class DeletedMessagesWithoutActorTests(AuthenticatedAPITestCase): cls.data = { 'actor': None, - 'creation': datetime.utcnow().isoformat(), + 'creation': datetime.now(tz=UTC).isoformat(), 'deletedmessage_set': [ { 'author': cls.author.id, @@ -58,7 +57,7 @@ class DeletedMessagesWithActorTests(AuthenticatedAPITestCase): cls.data = { 'actor': cls.actor.id, - 'creation': datetime.utcnow().isoformat(), + 'creation': datetime.now(tz=UTC).isoformat(), 'deletedmessage_set': [ { 'author': cls.author.id, @@ -90,7 +89,7 @@ class DeletedMessagesLogURLTests(AuthenticatedAPITestCase): cls.deletion_context = MessageDeletionContext.objects.create( actor=cls.actor, - creation=timezone.now() + creation=datetime.now(tz=UTC), ) def test_valid_log_url(self): diff --git a/pydis_site/apps/api/tests/test_documentation_links.py b/pydis_site/apps/api/tests/test_documentation_links.py index 4e238cbb..f4a332cb 100644 --- a/pydis_site/apps/api/tests/test_documentation_links.py +++ b/pydis_site/apps/api/tests/test_documentation_links.py @@ -1,7 +1,7 @@ from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import DocumentationLink +from pydis_site.apps.api.models import DocumentationLink class UnauthedDocumentationLinkAPITests(AuthenticatedAPITestCase): diff --git a/pydis_site/apps/api/tests/test_filterlists.py b/pydis_site/apps/api/tests/test_filterlists.py deleted file mode 100644 index 5a5bca60..00000000 --- a/pydis_site/apps/api/tests/test_filterlists.py +++ /dev/null @@ -1,122 +0,0 @@ -from django.urls import reverse - -from pydis_site.apps.api.models import FilterList -from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase - -URL = reverse('api:bot:filterlist-list') -JPEG_ALLOWLIST = { - "type": 'FILE_FORMAT', - "allowed": True, - "content": ".jpeg", -} -PNG_ALLOWLIST = { - "type": 'FILE_FORMAT', - "allowed": True, - "content": ".png", -} - - -class UnauthenticatedTests(AuthenticatedAPITestCase): - def setUp(self): - super().setUp() - self.client.force_authenticate(user=None) - - def test_cannot_read_allowedlist_list(self): - response = self.client.get(URL) - - self.assertEqual(response.status_code, 401) - - -class EmptyDatabaseTests(AuthenticatedAPITestCase): - @classmethod - def setUpTestData(cls): - FilterList.objects.all().delete() - - def test_returns_empty_object(self): - response = self.client.get(URL) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), []) - - -class FetchTests(AuthenticatedAPITestCase): - @classmethod - def setUpTestData(cls): - FilterList.objects.all().delete() - cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST) - cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST) - - def test_returns_name_in_list(self): - response = self.client.get(URL) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()[0]["content"], self.jpeg_format.content) - self.assertEqual(response.json()[1]["content"], self.png_format.content) - - def test_returns_single_item_by_id(self): - response = self.client.get(f'{URL}/{self.jpeg_format.id}') - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json().get("content"), self.jpeg_format.content) - - def test_returns_filter_list_types(self): - response = self.client.get(f'{URL}/get-types') - - self.assertEqual(response.status_code, 200) - for api_type, model_type in zip(response.json(), FilterList.FilterListType.choices): - self.assertEquals(api_type[0], model_type[0]) - self.assertEquals(api_type[1], model_type[1]) - - -class CreationTests(AuthenticatedAPITestCase): - @classmethod - def setUpTestData(cls): - FilterList.objects.all().delete() - - def test_returns_400_for_missing_params(self): - no_type_json = { - "allowed": True, - "content": ".jpeg" - } - no_allowed_json = { - "type": "FILE_FORMAT", - "content": ".jpeg" - } - no_content_json = { - "allowed": True, - "type": "FILE_FORMAT" - } - cases = [{}, no_type_json, no_allowed_json, no_content_json] - - for case in cases: - with self.subTest(case=case): - response = self.client.post(URL, data=case) - self.assertEqual(response.status_code, 400) - - def test_returns_201_for_successful_creation(self): - response = self.client.post(URL, data=JPEG_ALLOWLIST) - self.assertEqual(response.status_code, 201) - - def test_returns_400_for_duplicate_creation(self): - self.client.post(URL, data=JPEG_ALLOWLIST) - response = self.client.post(URL, data=JPEG_ALLOWLIST) - self.assertEqual(response.status_code, 400) - - -class DeletionTests(AuthenticatedAPITestCase): - @classmethod - def setUpTestData(cls): - FilterList.objects.all().delete() - cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST) - cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST) - - def test_deleting_unknown_id_returns_404(self): - response = self.client.delete(f"{URL}/200") - self.assertEqual(response.status_code, 404) - - def test_deleting_known_id_returns_204(self): - response = self.client.delete(f"{URL}/{self.jpeg_format.id}") - self.assertEqual(response.status_code, 204) - - response = self.client.get(f"{URL}/{self.jpeg_format.id}") - self.assertNotIn(self.png_format.content, response.json()) diff --git a/pydis_site/apps/api/tests/test_filters.py b/pydis_site/apps/api/tests/test_filters.py new file mode 100644 index 00000000..4cef1c8f --- /dev/null +++ b/pydis_site/apps/api/tests/test_filters.py @@ -0,0 +1,352 @@ +import contextlib +from dataclasses import dataclass +from datetime import timedelta +from typing import Any + +from django.db.models import Model +from django.urls import reverse + +from pydis_site.apps.api.models.bot.filters import Filter, FilterList +from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase + + +@dataclass() +class TestSequence: + model: type[Model] + route: str + object: dict[str, Any] + ignored_fields: tuple[str, ...] = () + + def url(self, detail: bool = False) -> str: + return reverse(f'api:bot:{self.route}-{"detail" if detail else "list"}') + + +FK_FIELDS: dict[type[Model], tuple[str, ...]] = { + FilterList: (), + Filter: ("filter_list",), +} + + +def get_test_sequences() -> dict[str, TestSequence]: + filter_list1_deny_dict = { + "name": "testname", + "list_type": 0, + "guild_pings": [], + "filter_dm": True, + "dm_pings": [], + "remove_context": False, + "bypass_roles": [], + "enabled": True, + "dm_content": "", + "dm_embed": "", + "infraction_type": "NONE", + "infraction_reason": "", + "infraction_duration": timedelta(seconds=0), + "infraction_channel": 0, + "disabled_channels": [], + "disabled_categories": [], + "enabled_channels": [], + "enabled_categories": [], + "send_alert": True + } + filter_list1_allow_dict = filter_list1_deny_dict.copy() + filter_list1_allow_dict["list_type"] = 1 + filter_list1_allow = FilterList(**filter_list1_allow_dict) + + return { + "filter_list1": TestSequence( + FilterList, + "filterlist", + filter_list1_deny_dict, + ignored_fields=("filters", "created_at", "updated_at") + ), + "filter_list2": TestSequence( + FilterList, + "filterlist", + { + "name": "testname2", + "list_type": 1, + "guild_pings": ["Moderators"], + "filter_dm": False, + "dm_pings": ["here"], + "remove_context": True, + "bypass_roles": ["123456"], + "enabled": False, + "dm_content": "testing testing", + "dm_embed": "one two three", + "infraction_type": "TIMEOUT", + "infraction_reason": "stop testing", + "infraction_duration": timedelta(seconds=10.5), + "infraction_channel": 123, + "disabled_channels": ["python-general"], + "disabled_categories": ["CODE JAM"], + "enabled_channels": ["mighty-mice"], + "enabled_categories": ["Lobby"], + "send_alert": False + }, + ignored_fields=("filters", "created_at", "updated_at") + ), + "filter": TestSequence( + Filter, + "filter", + { + "content": "bad word", + "description": "This is a really bad word.", + "additional_settings": "{'hi': 'there'}", + "guild_pings": None, + "filter_dm": None, + "dm_pings": None, + "remove_context": None, + "bypass_roles": None, + "enabled": None, + "dm_content": None, + "dm_embed": None, + "infraction_type": None, + "infraction_reason": None, + "infraction_duration": None, + "infraction_channel": None, + "disabled_channels": None, + "disabled_categories": None, + "enabled_channels": None, + "enabled_categories": None, + "send_alert": None, + "filter_list": filter_list1_allow + }, + ignored_fields=("created_at", "updated_at") + ), + } + + +def save_nested_objects(object_: Model, save_root: bool = True) -> None: + for field in FK_FIELDS.get(object_.__class__, ()): + value = getattr(object_, field) + save_nested_objects(value) + + if save_root: + object_.save() + + +def clean_test_json(json: dict) -> dict: + for key, value in json.items(): + if isinstance(value, Model): + json[key] = value.id + elif isinstance(value, timedelta): + json[key] = str(value.total_seconds()) + + return json + + +def clean_api_json(json: dict, sequence: TestSequence) -> dict: + for field in sequence.ignored_fields + ("id",): + with contextlib.suppress(KeyError): + del json[field] + + return json + + +def flatten_settings(json: dict) -> dict: + settings = json.pop("settings", {}) + flattened_settings = {} + for entry, value in settings.items(): + if isinstance(value, dict): + flattened_settings.update(value) + else: + flattened_settings[entry] = value + + json.update(flattened_settings) + + return json + + +class GenericFilterTests(AuthenticatedAPITestCase): + + def test_cannot_read_unauthenticated(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + self.client.force_authenticate(user=None) + + response = self.client.get(sequence.url()) + self.assertEqual(response.status_code, 401) + + def test_empty_database(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + response = self.client.get(sequence.url()) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_fetch(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + save_nested_objects(sequence.model(**sequence.object)) + + response = self.client.get(sequence.url()) + self.assertDictEqual( + clean_test_json(sequence.object), + clean_api_json(flatten_settings(response.json()[0]), sequence) + ) + + def test_fetch_by_id(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + saved = sequence.model(**sequence.object) + save_nested_objects(saved) + + response = self.client.get(f"{sequence.url()}/{saved.id}") + self.assertDictEqual( + clean_test_json(sequence.object), + clean_api_json(flatten_settings(response.json()), sequence) + ) + + def test_fetch_non_existing(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + response = self.client.get(f"{sequence.url()}/42") + self.assertEqual(response.status_code, 404) + self.assertDictEqual(response.json(), {'detail': 'Not found.'}) + + def test_creation(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + save_nested_objects(sequence.model(**sequence.object), False) + data = clean_test_json(sequence.object.copy()) + response = self.client.post(sequence.url(), data=data) + + self.assertEqual(response.status_code, 201) + self.assertDictEqual( + clean_api_json(flatten_settings(response.json()), sequence), + clean_test_json(sequence.object) + ) + + def test_creation_missing_field(self) -> None: + for name, sequence in get_test_sequences().items(): + ignored_fields = sequence.ignored_fields + ("id", "additional_settings") + with self.subTest(name=name): + saved = sequence.model(**sequence.object) + save_nested_objects(saved) + data = clean_test_json(sequence.object.copy()) + + for field in sequence.model._meta.get_fields(): + with self.subTest(field=field): + if field.null or field.name in ignored_fields: + continue + + test_data = data.copy() + del test_data[field.name] + + response = self.client.post(sequence.url(), data=test_data) + self.assertEqual(response.status_code, 400) + + def test_deletion(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + saved = sequence.model(**sequence.object) + save_nested_objects(saved) + + response = self.client.delete(f"{sequence.url()}/{saved.id}") + self.assertEqual(response.status_code, 204) + + def test_deletion_non_existing(self) -> None: + for name, sequence in get_test_sequences().items(): + with self.subTest(name=name): + sequence.model.objects.all().delete() + + response = self.client.delete(f"{sequence.url()}/42") + self.assertEqual(response.status_code, 404) + + +class FilterValidationTests(AuthenticatedAPITestCase): + + def test_filter_validation(self) -> None: + test_sequences = get_test_sequences() + base_filter = test_sequences["filter"] + base_filter_list = test_sequences["filter_list1"] + cases = ( + ({"infraction_reason": "hi"}, {}, 400), + ({"infraction_duration": timedelta(seconds=10)}, {}, 400), + ({"infraction_reason": "hi"}, {"infraction_type": "NOTE"}, 200), + ({"infraction_type": "TIMEOUT", "infraction_duration": timedelta(days=30)}, {}, 400), + ({"infraction_duration": timedelta(seconds=10)}, {"infraction_type": "TIMEOUT"}, 200), + ({"enabled_channels": ["admins"]}, {}, 200), + ({"disabled_channels": ["123"]}, {}, 200), + ({"enabled_categories": ["CODE JAM"]}, {}, 200), + ({"disabled_categories": ["CODE JAM"]}, {}, 200), + ({"enabled_channels": ["admins"], "disabled_channels": ["123", "admins"]}, {}, 400), + ({"enabled_categories": ["admins"], "disabled_categories": ["123", "admins"]}, {}, 400), + ({"enabled_channels": ["admins"]}, {"disabled_channels": ["123", "admins"]}, 400), + ({"enabled_categories": ["admins"]}, {"disabled_categories": ["123", "admins"]}, 400), + ) + + for filter_settings, filter_list_settings, response_code in cases: + with self.subTest( + f_settings=filter_settings, fl_settings=filter_list_settings, response=response_code + ): + base_filter.model.objects.all().delete() + base_filter_list.model.objects.all().delete() + + case_filter_dict = base_filter.object.copy() + case_fl_dict = base_filter_list.object.copy() + case_fl_dict.update(filter_list_settings) + + case_fl = base_filter_list.model(**case_fl_dict) + case_filter_dict["filter_list"] = case_fl + case_filter = base_filter.model(**case_filter_dict) + save_nested_objects(case_filter) + + filter_settings["filter_list"] = case_fl + response = self.client.patch( + f"{base_filter.url()}/{case_filter.id}", data=clean_test_json(filter_settings) + ) + self.assertEqual(response.status_code, response_code) + + def test_filter_list_validation(self) -> None: + test_sequences = get_test_sequences() + base_filter_list = test_sequences["filter_list1"] + cases = ( + ({"infraction_reason": "hi"}, 400), + ({"infraction_duration": timedelta(seconds=10)}, 400), + ({"infraction_type": "TIMEOUT", "infraction_duration": timedelta(days=30)}, 400), + ({"infraction_reason": "hi", "infraction_type": "NOTE"}, 200), + ({"infraction_duration": timedelta(seconds=10), "infraction_type": "TIMEOUT"}, 200), + ({"enabled_channels": ["admins"]}, 200), ({"disabled_channels": ["123"]}, 200), + ({"enabled_categories": ["CODE JAM"]}, 200), + ({"disabled_categories": ["CODE JAM"]}, 200), + ({"enabled_channels": ["admins"], "disabled_channels": ["123", "admins"]}, 400), + ({"enabled_categories": ["admins"], "disabled_categories": ["123", "admins"]}, 400), + ) + + for filter_list_settings, response_code in cases: + with self.subTest(fl_settings=filter_list_settings, response=response_code): + base_filter_list.model.objects.all().delete() + + case_fl_dict = base_filter_list.object.copy() + case_fl = base_filter_list.model(**case_fl_dict) + save_nested_objects(case_fl) + + response = self.client.patch( + f"{base_filter_list.url()}/{case_fl.id}", + data=clean_test_json(filter_list_settings) + ) + self.assertEqual(response.status_code, response_code) + + def test_filter_unique_constraint(self) -> None: + test_filter = get_test_sequences()["filter"] + test_filter.model.objects.all().delete() + test_filter_object = test_filter.model(**test_filter.object) + save_nested_objects(test_filter_object, False) + + response = self.client.post(test_filter.url(), data=clean_test_json(test_filter.object)) + self.assertEqual(response.status_code, 201) + + response = self.client.post(test_filter.url(), data=clean_test_json(test_filter.object)) + self.assertEqual(response.status_code, 400) diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py new file mode 100644 index 00000000..d36111c9 --- /dev/null +++ b/pydis_site/apps/api/tests/test_github_utils.py @@ -0,0 +1,289 @@ +import dataclasses +import datetime +import typing +import unittest +from unittest import mock + +import django.test +import httpx +import jwt +import rest_framework.response +import rest_framework.test +from django.urls import reverse + +from pydis_site import settings +from pydis_site.apps.api import github_utils + + +class GeneralUtilityTests(unittest.TestCase): + """Test the utility methods which do not fit in another class.""" + + def test_token_generation(self): + """Test that the a valid JWT token is generated.""" + def encode(payload: dict, _: str, algorithm: str, *args, **kwargs) -> str: + """ + Intercept the encode method. + + The result is encoded with an algorithm which does not require a PEM key, as it may + not be available in testing environments. + """ + self.assertEqual("RS256", algorithm, "The GitHub App JWT must be signed using RS256.") + return original_encode( + payload, "secret-encoding-key", *args, algorithm="HS256", **kwargs + ) + + original_encode = jwt.encode + with mock.patch("jwt.encode", new=encode): + token = github_utils.generate_token() + decoded = jwt.decode(token, "secret-encoding-key", algorithms=["HS256"]) + + delta = datetime.timedelta(minutes=10) + self.assertAlmostEqual(decoded["exp"] - decoded["iat"], delta.total_seconds()) + then = datetime.datetime.now(tz=datetime.UTC) + delta + self.assertLess(decoded["exp"], then.timestamp()) + + +class CheckRunTests(unittest.TestCase): + """Tests the check_run_status utility.""" + + run_kwargs: typing.Mapping = { + "name": "run_name", + "head_sha": "sha", + "status": "completed", + "conclusion": "success", + "created_at": datetime.datetime.now(tz=datetime.UTC).strftime(settings.GITHUB_TIMESTAMP_FORMAT), + "artifacts_url": "url", + } + + def test_completed_run(self): + """Test that an already completed run returns the correct URL.""" + final_url = "some_url_string_1234" + + kwargs = dict(self.run_kwargs, artifacts_url=final_url) + result = github_utils.check_run_status(github_utils.WorkflowRun(**kwargs)) + self.assertEqual(final_url, result) + + def test_pending_run(self): + """Test that a pending run raises the proper exception.""" + kwargs = dict(self.run_kwargs, status="pending") + with self.assertRaises(github_utils.RunPendingError): + github_utils.check_run_status(github_utils.WorkflowRun(**kwargs)) + + def test_timeout_error(self): + """Test that a timeout is declared after a certain duration.""" + kwargs = dict(self.run_kwargs, status="pending") + # Set the creation time to well before the MAX_RUN_TIME + # to guarantee the right conclusion + kwargs["created_at"] = ( + datetime.datetime.now(tz=datetime.UTC) + - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10) + ).strftime(settings.GITHUB_TIMESTAMP_FORMAT) + + with self.assertRaises(github_utils.RunTimeoutError): + github_utils.check_run_status(github_utils.WorkflowRun(**kwargs)) + + def test_failed_run(self): + """Test that a failed run raises the proper exception.""" + kwargs = dict(self.run_kwargs, conclusion="failed") + with self.assertRaises(github_utils.ActionFailedError): + github_utils.check_run_status(github_utils.WorkflowRun(**kwargs)) + + +def get_response_authorize(_: httpx.Client, request: httpx.Request, **__) -> httpx.Response: + """ + Helper method for the authorize tests. + + Requests are intercepted before being sent out, and the appropriate responses are returned. + """ + path = request.url.path + auth = request.headers.get("Authorization") + + if request.method == "GET": + if path == "/app/installations": + if auth == "bearer JWT initial token": + return httpx.Response(200, request=request, json=[{ + "account": {"login": "VALID_OWNER"}, + "access_tokens_url": "https://example.com/ACCESS_TOKEN_URL" + }]) + return httpx.Response( + 401, json={"error": "auth app/installations"}, request=request + ) + + elif path == "/installation/repositories": # noqa: RET505 + if auth == "bearer app access token": + return httpx.Response(200, request=request, json={ + "repositories": [{ + "name": "VALID_REPO" + }] + }) + return httpx.Response( # pragma: no cover + 401, json={"error": "auth installation/repositories"}, request=request + ) + + elif request.method == "POST": # noqa: RET505 + if path == "/ACCESS_TOKEN_URL": + if auth == "bearer JWT initial token": + return httpx.Response(200, request=request, json={"token": "app access token"}) + return httpx.Response(401, json={"error": "auth access_token"}, request=request) # pragma: no cover + + # Reaching this point means something has gone wrong + return httpx.Response(500, request=request) # pragma: no cover + + [email protected]("httpx.Client.send", new=get_response_authorize) [email protected](github_utils, "generate_token", new=mock.Mock(return_value="JWT initial token")) +class AuthorizeTests(unittest.TestCase): + """Test the authorize utility.""" + + def test_invalid_apps_auth(self): + """Test that an exception is raised if authorization was attempted with an invalid token.""" + with mock.patch.object(github_utils, "generate_token", return_value="Invalid token"): # noqa: SIM117 + with self.assertRaises(httpx.HTTPStatusError) as error: + github_utils.authorize("VALID_OWNER", "VALID_REPO") + + exception: httpx.HTTPStatusError = error.exception + self.assertEqual(401, exception.response.status_code) + self.assertEqual("auth app/installations", exception.response.json()["error"]) + + def test_missing_repo(self): + """Test that an exception is raised when the selected owner or repo are not available.""" + with self.assertRaises(github_utils.NotFoundError): + github_utils.authorize("INVALID_OWNER", "VALID_REPO") + with self.assertRaises(github_utils.NotFoundError): + github_utils.authorize("VALID_OWNER", "INVALID_REPO") + + def test_valid_authorization(self): + """Test that an accessible repository can be accessed.""" + client = github_utils.authorize("VALID_OWNER", "VALID_REPO") + self.assertEqual("bearer app access token", client.headers.get("Authorization")) + + +class ArtifactFetcherTests(unittest.TestCase): + """Test the get_artifact utility.""" + + @staticmethod + def get_response_get_artifact(request: httpx.Request, **_) -> httpx.Response: + """ + Helper method for the get_artifact tests. + + Requests are intercepted before being sent out, and the appropriate responses are returned. + """ + path = request.url.path + + if "force_error" in path: + return httpx.Response(404, request=request) + + if request.method == "GET": + if path == "/repos/owner/repo/actions/runs": + run = github_utils.WorkflowRun( + name="action_name", + head_sha="action_sha", + created_at=( + datetime.datetime + .now(tz=datetime.UTC) + .strftime(settings.GITHUB_TIMESTAMP_FORMAT) + ), + status="completed", + conclusion="success", + artifacts_url="artifacts_url" + ) + return httpx.Response( + 200, request=request, json={"workflow_runs": [dataclasses.asdict(run)]} + ) + elif path == "/artifact_url": # noqa: RET505 + return httpx.Response( + 200, request=request, json={"artifacts": [{ + "name": "artifact_name", + "archive_download_url": "artifact_download_url" + }]} + ) + elif path == "/artifact_download_url": + response = httpx.Response(302, request=request) + response.next_request = httpx.Request( + "GET", + httpx.URL("https://final_download.url") + ) + return response + + # Reaching this point means something has gone wrong + return httpx.Response(500, request=request) # pragma: no cover + + def setUp(self) -> None: + self.call_args = ["owner", "repo", "action_sha", "action_name", "artifact_name"] + self.client = httpx.Client(base_url="https://example.com") + + self.patchers = [ + mock.patch.object(self.client, "send", new=self.get_response_get_artifact), + mock.patch.object(github_utils, "authorize", return_value=self.client), + mock.patch.object(github_utils, "check_run_status", return_value="artifact_url"), + ] + + for patcher in self.patchers: + patcher.start() + + def tearDown(self) -> None: + for patcher in self.patchers: + patcher.stop() + + def test_client_closed_on_errors(self): + """Test that the client is terminated even if an error occurs at some point.""" + self.call_args[0] = "force_error" + with self.assertRaises(httpx.HTTPStatusError): + github_utils.get_artifact(*self.call_args) + self.assertTrue(self.client.is_closed) + + def test_missing(self): + """Test that an exception is raised if the requested artifact was not found.""" + cases = ( + "invalid sha", + "invalid action name", + "invalid artifact name", + ) + for i, name in enumerate(cases, 2): + with self.subTest(f"Test {name} raises an error"): + new_args = self.call_args.copy() + new_args[i] = name + + with self.assertRaises(github_utils.NotFoundError): + github_utils.get_artifact(*new_args) + + def test_valid(self): + """Test that the correct download URL is returned for valid requests.""" + url = github_utils.get_artifact(*self.call_args) + self.assertEqual("https://final_download.url", url) + self.assertTrue(self.client.is_closed) + + [email protected](github_utils, "get_artifact") +class GitHubArtifactViewTests(django.test.TestCase): + """Test the GitHub artifact fetch API view.""" + + def setUp(self): + self.kwargs = { + "owner": "test_owner", + "repo": "test_repo", + "sha": "test_sha", + "action_name": "test_action", + "artifact_name": "test_artifact", + } + self.url = reverse("api:github-artifacts", kwargs=self.kwargs) + + def test_correct_artifact(self, artifact_mock: mock.Mock): + """Test a proper response is returned with proper input.""" + artifact_mock.return_value = "final download url" + result = self.client.get(self.url) + + self.assertIsInstance(result, rest_framework.response.Response) + self.assertEqual({"url": artifact_mock.return_value}, result.data) + + def test_failed_fetch(self, artifact_mock: mock.Mock): + """Test that a proper error is returned when the request fails.""" + artifact_mock.side_effect = github_utils.NotFoundError("Test error message") + result = self.client.get(self.url) + + self.assertIsInstance(result, rest_framework.response.Response) + self.assertEqual({ + "error_type": github_utils.NotFoundError.__name__, + "error": "Test error message", + "requested_resource": "/".join(self.kwargs.values()) + }, result.data) diff --git a/pydis_site/apps/api/tests/test_github_webhook_filter.py b/pydis_site/apps/api/tests/test_github_webhook_filter.py new file mode 100644 index 00000000..8ca60511 --- /dev/null +++ b/pydis_site/apps/api/tests/test_github_webhook_filter.py @@ -0,0 +1,62 @@ +from unittest import mock +from urllib.error import HTTPError + +from django.urls import reverse +from rest_framework.test import APITestCase + +from pydis_site.apps.api.views import GitHubWebhookFilterView + +class GitHubWebhookFilterAPITests(APITestCase): + def test_ignores_bot_sender(self): + url = reverse('api:github-webhook-filter', args=('id', 'token')) + payload = {'sender': {'login': 'limette', 'type': 'bot'}} + headers = {'X-GitHub-Event': 'pull_request_review'} + response = self.client.post(url, data=payload, headers=headers) + self.assertEqual(response.status_code, 203) + + def test_accepts_interesting_events(self): + url = reverse('api:github-webhook-filter', args=('id', 'token')) + payload = { + 'ref': 'refs/heads/master', + 'pull_request': { + 'user': { + 'login': "lemon", + } + }, + 'review': { + 'state': 'commented', + 'body': "Amazing!!!" + }, + 'repository': { + 'name': 'black', + 'owner': { + 'login': 'psf', + } + } + } + headers = {'X-GitHub-Event': 'pull_request_review'} + + with mock.patch('urllib.request.urlopen') as urlopen: + urlopen.return_value = mock.MagicMock() + context_mock = urlopen.return_value.__enter__.return_value + context_mock.status = 299 + context_mock.getheaders.return_value = [('X-Clacks-Overhead', 'Joe Armstrong')] + context_mock.read.return_value = b'{"status": "ok"}' + + response = self.client.post(url, data=payload, headers=headers) + self.assertEqual(response.status_code, context_mock.status) + self.assertEqual(response.headers.get('X-Clacks-Overhead'), 'Joe Armstrong') + + def test_rate_limit_is_logged_to_sentry(self): + url = reverse('api:github-webhook-filter', args=('id', 'token')) + payload = {} + headers = {'X-GitHub-Event': 'pull_request_review'} + with ( + mock.patch('urllib.request.urlopen') as urlopen, + mock.patch.object(GitHubWebhookFilterView, "logger") as logger, + ): + urlopen.side_effect = HTTPError(None, 429, 'Too Many Requests', {}, None) + logger.warning = mock.PropertyMock() + self.client.post(url, data=payload, headers=headers) + + logger.warning.assert_called_once() diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index b3dd16ee..f1e54b1e 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -1,14 +1,15 @@ import datetime -from datetime import datetime as dt, timedelta, timezone +from datetime import UTC, datetime as dt, timedelta from unittest.mock import patch from urllib.parse import quote +from django.db import transaction from django.db.utils import IntegrityError from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import Infraction, User -from ..serializers import InfractionSerializer +from pydis_site.apps.api.models import Infraction, User +from pydis_site.apps.api.serializers import InfractionSerializer class UnauthenticatedTests(AuthenticatedAPITestCase): @@ -55,23 +56,26 @@ class InfractionTests(AuthenticatedAPITestCase): type='ban', reason='He terk my jerb!', hidden=True, - expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc), - active=True + inserted_at=dt(2020, 10, 10, 0, 0, 0, tzinfo=UTC), + expires_at=dt(5018, 11, 20, 15, 52, tzinfo=UTC), + active=True, ) cls.ban_inactive = Infraction.objects.create( user_id=cls.user.id, actor_id=cls.user.id, type='ban', reason='James is an ass, and we won\'t be working with him again.', - active=False + active=False, + inserted_at=dt(2020, 10, 10, 0, 1, 0, tzinfo=UTC), ) - cls.mute_permanent = Infraction.objects.create( + cls.timeout_permanent = Infraction.objects.create( user_id=cls.user.id, actor_id=cls.user.id, - type='mute', + type='timeout', reason='He has a filthy mouth and I am his soap.', active=True, - expires_at=None + inserted_at=dt(2020, 10, 10, 0, 2, 0, tzinfo=UTC), + expires_at=None, ) cls.superstar_expires_soon = Infraction.objects.create( user_id=cls.user.id, @@ -79,7 +83,8 @@ class InfractionTests(AuthenticatedAPITestCase): type='superstar', reason='This one doesn\'t matter anymore.', active=True, - expires_at=datetime.datetime.utcnow() + datetime.timedelta(hours=5) + inserted_at=dt(2020, 10, 10, 0, 3, 0, tzinfo=UTC), + expires_at=dt.now(UTC) + datetime.timedelta(hours=5), ) cls.voiceban_expires_later = Infraction.objects.create( user_id=cls.user.id, @@ -87,7 +92,8 @@ class InfractionTests(AuthenticatedAPITestCase): type='voice_ban', reason='Jet engine mic', active=True, - expires_at=datetime.datetime.utcnow() + datetime.timedelta(days=5) + inserted_at=dt(2020, 10, 10, 0, 4, 0, tzinfo=UTC), + expires_at=dt.now(UTC) + datetime.timedelta(days=5), ) def test_list_all(self): @@ -101,7 +107,7 @@ class InfractionTests(AuthenticatedAPITestCase): self.assertEqual(len(infractions), 5) self.assertEqual(infractions[0]['id'], self.voiceban_expires_later.id) self.assertEqual(infractions[1]['id'], self.superstar_expires_soon.id) - self.assertEqual(infractions[2]['id'], self.mute_permanent.id) + self.assertEqual(infractions[2]['id'], self.timeout_permanent.id) self.assertEqual(infractions[3]['id'], self.ban_inactive.id) self.assertEqual(infractions[4]['id'], self.ban_hidden.id) @@ -128,7 +134,7 @@ class InfractionTests(AuthenticatedAPITestCase): def test_filter_permanent_false(self): url = reverse('api:bot:infraction-list') - response = self.client.get(f'{url}?type=mute&permanent=false') + response = self.client.get(f'{url}?type=timeout&permanent=false') self.assertEqual(response.status_code, 200) infractions = response.json() @@ -137,17 +143,17 @@ class InfractionTests(AuthenticatedAPITestCase): def test_filter_permanent_true(self): url = reverse('api:bot:infraction-list') - response = self.client.get(f'{url}?type=mute&permanent=true') + response = self.client.get(f'{url}?type=timeout&permanent=true') self.assertEqual(response.status_code, 200) infractions = response.json() - self.assertEqual(infractions[0]['id'], self.mute_permanent.id) + self.assertEqual(infractions[0]['id'], self.timeout_permanent.id) def test_filter_after(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) - response = self.client.get(f'{url}?type=superstar&expires_after={target_time.isoformat()}') + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=5) + response = self.client.get(url, {'type': 'superstar', 'expires_after': target_time.isoformat()}) self.assertEqual(response.status_code, 200) infractions = response.json() @@ -155,8 +161,8 @@ class InfractionTests(AuthenticatedAPITestCase): def test_filter_before(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) - response = self.client.get(f'{url}?type=superstar&expires_before={target_time.isoformat()}') + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=5) + response = self.client.get(url, {'type': 'superstar', 'expires_before': target_time.isoformat()}) self.assertEqual(response.status_code, 200) infractions = response.json() @@ -168,22 +174,23 @@ class InfractionTests(AuthenticatedAPITestCase): response = self.client.get(f'{url}?expires_after=gibberish') self.assertEqual(response.status_code, 400) - self.assertEqual(list(response.json())[0], "expires_after") + self.assertEqual(next(iter(response.json())), "expires_after") def test_filter_before_invalid(self): url = reverse('api:bot:infraction-list') response = self.client.get(f'{url}?expires_before=000000000') self.assertEqual(response.status_code, 400) - self.assertEqual(list(response.json())[0], "expires_before") + self.assertEqual(next(iter(response.json())), "expires_before") def test_after_before_before(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=4) - target_time_late = datetime.datetime.utcnow() + datetime.timedelta(hours=6) + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=4) + target_time_late = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=6) response = self.client.get( - f'{url}?expires_before={target_time_late.isoformat()}' - f'&expires_after={target_time.isoformat()}' + url, + {'expires_before': target_time_late.isoformat(), + 'expires_after': target_time.isoformat()}, ) self.assertEqual(response.status_code, 200) @@ -192,11 +199,12 @@ class InfractionTests(AuthenticatedAPITestCase): def test_after_after_before_invalid(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) - target_time_late = datetime.datetime.utcnow() + datetime.timedelta(hours=9) + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=5) + target_time_late = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=9) response = self.client.get( - f'{url}?expires_before={target_time.isoformat()}' - f'&expires_after={target_time_late.isoformat()}' + url, + {'expires_before': target_time.isoformat(), + 'expires_after': target_time_late.isoformat()}, ) self.assertEqual(response.status_code, 400) @@ -206,8 +214,11 @@ class InfractionTests(AuthenticatedAPITestCase): def test_permanent_after_invalid(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) - response = self.client.get(f'{url}?permanent=true&expires_after={target_time.isoformat()}') + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=5) + response = self.client.get( + url, + {'permanent': 'true', 'expires_after': target_time.isoformat()}, + ) self.assertEqual(response.status_code, 400) errors = list(response.json()) @@ -215,8 +226,11 @@ class InfractionTests(AuthenticatedAPITestCase): def test_permanent_before_invalid(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) - response = self.client.get(f'{url}?permanent=true&expires_before={target_time.isoformat()}') + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=5) + response = self.client.get( + url, + {'permanent': 'true', 'expires_before': target_time.isoformat()}, + ) self.assertEqual(response.status_code, 400) errors = list(response.json()) @@ -224,9 +238,10 @@ class InfractionTests(AuthenticatedAPITestCase): def test_nonpermanent_before(self): url = reverse('api:bot:infraction-list') - target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=6) + target_time = datetime.datetime.now(tz=UTC) + datetime.timedelta(hours=6) response = self.client.get( - f'{url}?permanent=false&expires_before={target_time.isoformat()}' + url, + {'permanent': 'false', 'expires_before': target_time.isoformat()}, ) self.assertEqual(response.status_code, 200) @@ -235,7 +250,7 @@ class InfractionTests(AuthenticatedAPITestCase): def test_filter_manytypes(self): url = reverse('api:bot:infraction-list') - response = self.client.get(f'{url}?types=mute,ban') + response = self.client.get(f'{url}?types=timeout,ban') self.assertEqual(response.status_code, 200) infractions = response.json() @@ -243,7 +258,7 @@ class InfractionTests(AuthenticatedAPITestCase): def test_types_type_invalid(self): url = reverse('api:bot:infraction-list') - response = self.client.get(f'{url}?types=mute,ban&type=superstar') + response = self.client.get(f'{url}?types=timeout,ban&type=superstar') self.assertEqual(response.status_code, 400) errors = list(response.json()) @@ -355,7 +370,7 @@ class CreationTests(AuthenticatedAPITestCase): infraction = Infraction.objects.get(id=response.json()['id']) self.assertAlmostEqual( infraction.inserted_at, - dt.now(timezone.utc), + dt.now(UTC), delta=timedelta(seconds=2) ) self.assertEqual(infraction.expires_at.isoformat(), data['expires_at']) @@ -492,6 +507,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, @@ -512,10 +528,10 @@ class CreationTests(AuthenticatedAPITestCase): def test_returns_400_for_second_active_infraction_of_the_same_type(self): """Test if the API rejects a second active infraction of the same type for a given user.""" url = reverse('api:bot:infraction-list') - active_infraction_types = ('mute', 'ban', 'superstar') + active_infraction_types = ('timeout', 'ban', 'superstar') for infraction_type in active_infraction_types: - with self.subTest(infraction_type=infraction_type): + with self.subTest(infraction_type=infraction_type), transaction.atomic(): first_active_infraction = { 'user': self.user.id, 'actor': self.user.id, @@ -554,7 +570,7 @@ class CreationTests(AuthenticatedAPITestCase): first_active_infraction = { 'user': self.user.id, 'actor': self.user.id, - 'type': 'mute', + 'type': 'timeout', 'reason': 'Be silent!', 'hidden': True, 'active': True, @@ -641,9 +657,9 @@ class CreationTests(AuthenticatedAPITestCase): Infraction.objects.create( user=self.user, actor=self.user, - type="mute", + type="timeout", active=True, - reason="The first active mute" + reason="The first active timeout" ) def test_unique_constraint_accepts_active_infractions_for_different_users(self): @@ -798,7 +814,7 @@ class SerializerTests(AuthenticatedAPITestCase): actor_id=self.user.id, type=_type, reason='A reason.', - expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc), + expires_at=dt(5018, 11, 20, 15, 52, tzinfo=UTC), active=active ) @@ -811,22 +827,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 5c9ddea4..456ac408 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -1,14 +1,14 @@ -from datetime import datetime as dt +from datetime import UTC, datetime as dt 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, DocumentationLink, + Filter, + FilterList, Infraction, - Message, MessageDeletionContext, Nomination, NominationEntry, @@ -41,7 +41,7 @@ class NitroMessageLengthTest(TestCase): self.context = MessageDeletionContext.objects.create( id=50, actor=self.user, - creation=dt.utcnow() + creation=dt.now(UTC) ) def test_create(self): @@ -99,17 +99,26 @@ class StringDunderMethodTests(SimpleTestCase): name='shawn', discriminator=555, ), - creation=dt.utcnow() + creation=dt.now(UTC) ), embeds=[] ), DocumentationLink( 'test', 'http://example.com', 'http://example.com' ), + FilterList( + name="forbidden_duckies", + list_type=0, + ), + Filter( + content="ducky_nsfw", + description="This ducky is totally inappropriate!", + additional_settings=None, + ), OffensiveMessage( id=602951077675139072, channel_id=291284109232308226, - delete_date=dt(3000, 1, 1) + delete_date=dt(3000, 1, 1, tzinfo=UTC) ), OffTopicChannelName(name='bob-the-builders-playground'), Role( @@ -117,24 +126,13 @@ 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, name='shawn', discriminator=555, ), - creation=dt.utcnow() + creation=dt.now(tz=UTC) ), User( id=5, @@ -153,7 +151,7 @@ class StringDunderMethodTests(SimpleTestCase): hidden=True, type='kick', reason='He terk my jerb!', - expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) + expires_at=dt(5018, 11, 20, 15, 52, tzinfo=UTC) ), Reminder( author=User( @@ -167,7 +165,7 @@ class StringDunderMethodTests(SimpleTestCase): '267624335836053506/291284109232308226/463087129459949587' ), content="oh no", - expiration=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) + expiration=dt(5018, 11, 20, 15, 52, tzinfo=UTC) ), NominationEntry( nomination_id=self.nomination.id, diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py index 62b2314c..e4dfe36a 100644 --- a/pydis_site/apps/api/tests/test_nominations.py +++ b/pydis_site/apps/api/tests/test_nominations.py @@ -1,9 +1,9 @@ -from datetime import datetime as dt, timedelta, timezone +from datetime import UTC, datetime as dt, timedelta from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import Nomination, NominationEntry, User +from pydis_site.apps.api.models import Nomination, NominationEntry, User class CreationTests(AuthenticatedAPITestCase): @@ -38,7 +38,7 @@ class CreationTests(AuthenticatedAPITestCase): ) self.assertAlmostEqual( nomination.inserted_at, - dt.now(timezone.utc), + dt.now(UTC), delta=timedelta(seconds=2) ) self.assertEqual(nomination.user.id, data['user']) @@ -254,7 +254,7 @@ class NominationTests(AuthenticatedAPITestCase): def test_returns_400_on_frozen_field_update(self): url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,)) data = { - 'user': "Theo Katzman" + 'user': 1234 } response = self.client.patch(url, data=data) @@ -319,7 +319,7 @@ class NominationTests(AuthenticatedAPITestCase): self.assertAlmostEqual( nomination.ended_at, - dt.now(timezone.utc), + dt.now(UTC), delta=timedelta(seconds=2) ) self.assertFalse(nomination.active) @@ -524,3 +524,35 @@ class NominationTests(AuthenticatedAPITestCase): self.assertEqual(response.json(), { 'actor': ["The actor doesn't exist or has not nominated the user."] }) + + def test_patch_nomination_set_thread_id_of_active_nomination(self): + url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,)) + data = {'thread_id': 9876543210} + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 200) + + def test_patch_nomination_set_thread_id_and_reviewed_of_active_nomination(self): + url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,)) + data = {'thread_id': 9876543210, "reviewed": True} + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 200) + + def test_modifying_thread_id_when_ending_nomination(self): + url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,)) + data = {'thread_id': 9876543210, 'active': False, 'end_reason': "What?"} + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'thread_id': ['This field cannot be set when ending a nomination.'] + }) + + def test_patch_thread_id_for_inactive_nomination(self): + url = reverse('api:bot:nomination-detail', args=(self.inactive_nomination.id,)) + data = {'thread_id': 9876543210} + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'thread_id': ['This field cannot be set if the nomination is inactive.'] + }) 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..315f707d 100644 --- a/pydis_site/apps/api/tests/test_off_topic_channel_names.py +++ b/pydis_site/apps/api/tests/test_off_topic_channel_names.py @@ -1,7 +1,7 @@ from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import OffTopicChannelName +from pydis_site.apps.api.models import OffTopicChannelName class UnauthenticatedTests(AuthenticatedAPITestCase): @@ -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_offensive_message.py b/pydis_site/apps/api/tests/test_offensive_message.py index 3cf95b75..2dc60bc3 100644 --- a/pydis_site/apps/api/tests/test_offensive_message.py +++ b/pydis_site/apps/api/tests/test_offensive_message.py @@ -3,20 +3,31 @@ import datetime from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import OffensiveMessage +from pydis_site.apps.api.models import OffensiveMessage + + +def create_offensive_message() -> OffensiveMessage: + """Creates and returns an `OffensiveMessgage` record for tests.""" + delete_at = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=1) + + return OffensiveMessage.objects.create( + id=602951077675139072, + channel_id=291284109232308226, + delete_date=delete_at, + ) class CreationTests(AuthenticatedAPITestCase): def test_accept_valid_data(self): url = reverse('api:bot:offensivemessage-list') - delete_at = datetime.datetime.now() + datetime.timedelta(days=1) + delete_at = datetime.datetime.now() + datetime.timedelta(days=1) # noqa: DTZ005 data = { 'id': '602951077675139072', 'channel_id': '291284109232308226', 'delete_date': delete_at.isoformat()[:-1] } - aware_delete_at = delete_at.replace(tzinfo=datetime.timezone.utc) + aware_delete_at = delete_at.replace(tzinfo=datetime.UTC) response = self.client.post(url, data=data) self.assertEqual(response.status_code, 201) @@ -32,7 +43,7 @@ class CreationTests(AuthenticatedAPITestCase): def test_returns_400_on_non_future_date(self): url = reverse('api:bot:offensivemessage-list') - delete_at = datetime.datetime.now() - datetime.timedelta(days=1) + delete_at = datetime.datetime.now() - datetime.timedelta(days=1) # noqa: DTZ005 data = { 'id': '602951077675139072', 'channel_id': '291284109232308226', @@ -46,7 +57,7 @@ class CreationTests(AuthenticatedAPITestCase): def test_returns_400_on_negative_id_or_channel_id(self): url = reverse('api:bot:offensivemessage-list') - delete_at = datetime.datetime.now() + datetime.timedelta(days=1) + delete_at = datetime.datetime.now() + datetime.timedelta(days=1) # noqa: DTZ005 data = { 'id': '602951077675139072', 'channel_id': '291284109232308226', @@ -72,8 +83,8 @@ class CreationTests(AuthenticatedAPITestCase): class ListTests(AuthenticatedAPITestCase): @classmethod def setUpTestData(cls): - delete_at = datetime.datetime.now() + datetime.timedelta(days=1) - aware_delete_at = delete_at.replace(tzinfo=datetime.timezone.utc) + delete_at = datetime.datetime.now() + datetime.timedelta(days=1) # noqa: DTZ005 + aware_delete_at = delete_at.replace(tzinfo=datetime.UTC) cls.messages = [ { @@ -111,13 +122,7 @@ class ListTests(AuthenticatedAPITestCase): class DeletionTests(AuthenticatedAPITestCase): @classmethod def setUpTestData(cls): - delete_at = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) - - cls.valid_offensive_message = OffensiveMessage.objects.create( - id=602951077675139072, - channel_id=291284109232308226, - delete_date=delete_at.isoformat() - ) + cls.valid_offensive_message = create_offensive_message() def test_delete_data(self): url = reverse( @@ -132,24 +137,53 @@ class DeletionTests(AuthenticatedAPITestCase): ) -class NotAllowedMethodsTests(AuthenticatedAPITestCase): +class UpdateOffensiveMessageTestCase(AuthenticatedAPITestCase): @classmethod def setUpTestData(cls): - delete_at = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + cls.message = create_offensive_message() + cls.in_one_week = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=7) + + def test_updating_message(self): + url = reverse('api:bot:offensivemessage-detail', args=(self.message.id,)) + data = {'delete_date': self.in_one_week.isoformat()} + update_response = self.client.patch(url, data=data) + self.assertEqual(update_response.status_code, 200) - cls.valid_offensive_message = OffensiveMessage.objects.create( - id=602951077675139072, - channel_id=291284109232308226, - delete_date=delete_at.isoformat() + self.message.refresh_from_db() + self.assertAlmostEqual( + self.message.delete_date, + self.in_one_week, + delta=datetime.timedelta(seconds=1), ) - def test_returns_405_for_patch_and_put_requests(self): - url = reverse( - 'api:bot:offensivemessage-detail', args=(self.valid_offensive_message.id,) + def test_updating_write_once_fields(self): + """Fields such as the channel ID may not be updated.""" + url = reverse('api:bot:offensivemessage-detail', args=(self.message.id,)) + data = {'channel_id': self.message.channel_id + 1} + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {'channel_id': ["This field cannot be updated."]}) + + def test_updating_nonexistent_message(self): + url = reverse('api:bot:offensivemessage-detail', args=(self.message.id + 1,)) + data = {'delete_date': self.in_one_week} + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 404) + self.message.refresh_from_db() + self.assertNotAlmostEqual( + self.message.delete_date, + self.in_one_week, + delta=datetime.timedelta(seconds=1), ) - not_allowed_methods = (self.client.patch, self.client.put) - for method in not_allowed_methods: - with self.subTest(method=method): - response = method(url, {}) - self.assertEqual(response.status_code, 405) + +class NotAllowedMethodsTests(AuthenticatedAPITestCase): + @classmethod + def setUpTestData(cls): + cls.message = create_offensive_message() + + def test_returns_405_for_get(self): + url = reverse('api:bot:offensivemessage-detail', args=(self.message.id,)) + response = self.client.get(url) + self.assertEqual(response.status_code, 405) diff --git a/pydis_site/apps/api/tests/test_reminders.py b/pydis_site/apps/api/tests/test_reminders.py index 709685bc..98e93bb7 100644 --- a/pydis_site/apps/api/tests/test_reminders.py +++ b/pydis_site/apps/api/tests/test_reminders.py @@ -1,10 +1,10 @@ -from datetime import datetime +from datetime import UTC, datetime from django.forms.models import model_to_dict from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import Reminder, User +from pydis_site.apps.api.models import Reminder, User class UnauthedReminderAPITests(AuthenticatedAPITestCase): @@ -59,7 +59,7 @@ class ReminderCreationTests(AuthenticatedAPITestCase): data = { 'author': self.author.id, 'content': 'Remember to...wait what was it again?', - 'expiration': datetime.utcnow().isoformat(), + 'expiration': datetime.now(tz=UTC).isoformat(), 'jump_url': "https://www.google.com", 'channel_id': 123, 'mentions': [8888, 9999], @@ -91,7 +91,7 @@ class ReminderDeletionTests(AuthenticatedAPITestCase): cls.reminder = Reminder.objects.create( author=cls.author, content="Don't forget to set yourself a reminder", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(UTC), jump_url="https://www.decliningmentalfaculties.com", channel_id=123 ) @@ -122,7 +122,7 @@ class ReminderListTests(AuthenticatedAPITestCase): cls.reminder_one = Reminder.objects.create( author=cls.author, content="We should take Bikini Bottom, and push it somewhere else!", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(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(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(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(UTC), jump_url="https://www.decliningmentalfaculties.com", channel_id=123 ) diff --git a/pydis_site/apps/api/tests/test_roles.py b/pydis_site/apps/api/tests/test_roles.py index 73c80c77..d3031990 100644 --- a/pydis_site/apps/api/tests/test_roles.py +++ b/pydis_site/apps/api/tests/test_roles.py @@ -1,7 +1,7 @@ from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import Role, User +from pydis_site.apps.api.models import Role, User class CreationTests(AuthenticatedAPITestCase): diff --git a/pydis_site/apps/api/tests/test_rules.py b/pydis_site/apps/api/tests/test_rules.py index d08c5fae..14412b90 100644 --- a/pydis_site/apps/api/tests/test_rules.py +++ b/pydis_site/apps/api/tests/test_rules.py @@ -1,7 +1,11 @@ +import itertools +import re +from pathlib import Path + from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..views import RulesView +from pydis_site.apps.api.views import RulesView class RuleAPITests(AuthenticatedAPITestCase): @@ -33,3 +37,37 @@ class RuleAPITests(AuthenticatedAPITestCase): url = reverse('api:rules') response = self.client.get(url + '?link_format=unknown') self.assertEqual(response.status_code, 400) + + +class RuleCorrectnessTests(AuthenticatedAPITestCase): + """Verifies that the rules from the API and by the static rules in the content app match.""" + + @classmethod + def setUpTestData(cls): + cls.markdown_rule_re = re.compile(r'^> \d+\. (.*)$') + + def test_rules_in_markdown_file_roughly_equal_api_rules(self) -> None: + url = reverse('api:rules') + api_response = self.client.get(url + '?link_format=md') + api_rules = tuple(rule for (rule, _tags) in api_response.json()) + + markdown_rules_path = ( + Path(__file__).parent.parent.parent / 'content' / 'resources' / 'rules.md' + ) + + markdown_rules = [] + for line in markdown_rules_path.read_text(encoding="utf8").splitlines(): + matches = self.markdown_rule_re.match(line) + if matches is not None: + markdown_rules.append(matches.group(1)) + + zipper = itertools.zip_longest(api_rules, markdown_rules) + for idx, (api_rule, markdown_rule) in enumerate(zipper): + with self.subTest(f"Rule {idx}"): + self.assertIsNotNone( + markdown_rule, f"The API has more rules than {markdown_rules_path}" + ) + self.assertIsNotNone( + api_rule, f"{markdown_rules_path} has more rules than the API endpoint" + ) + self.assertEqual(markdown_rule, api_rule) diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py index e21bb32b..cff4a825 100644 --- a/pydis_site/apps/api/tests/test_users.py +++ b/pydis_site/apps/api/tests/test_users.py @@ -1,11 +1,12 @@ +import random from unittest.mock import Mock, patch from django.urls import reverse from .base import AuthenticatedAPITestCase -from ..models import Infraction, Role, User -from ..models.bot.metricity import NotFoundError -from ..viewsets.bot.user import UserListPagination +from pydis_site.apps.api.models import Infraction, Role, User +from pydis_site.apps.api.models.bot.metricity import NotFoundError +from pydis_site.apps.api.viewsets.bot.user import UserListPagination class UnauthedUserAPITests(AuthenticatedAPITestCase): @@ -468,18 +469,17 @@ class UserMetricityTests(AuthenticatedAPITestCase): with self.subTest( voice_infractions=case['voice_infractions'], voice_gate_blocked=case['voice_gate_blocked'] - ): - with patch("pydis_site.apps.api.viewsets.bot.user.Infraction.objects.filter") as p: - p.return_value = case['voice_infractions'] + ), 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) + url = reverse('api:bot:user-metricity-data', args=[0]) + response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.json()["voice_gate_blocked"], - case["voice_gate_blocked"] - ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json()["voice_gate_blocked"], + case["voice_gate_blocked"] + ) def test_metricity_review_data(self): # Given @@ -501,6 +501,90 @@ class UserMetricityTests(AuthenticatedAPITestCase): "total_messages": total_messages }) + def test_metricity_activity_data(self): + # Given + self.mock_no_metricity_user() # Other functions shouldn't be used. + self.metricity.total_messages_in_past_n_days.return_value = [(0, 10)] + + # When + url = reverse("api:bot:user-metricity-activity-data") + response = self.client.post( + url, + data=[0, 1], + QUERY_STRING="days=10", + ) + + # Then + self.assertEqual(response.status_code, 200) + self.metricity.total_messages_in_past_n_days.assert_called_once_with(["0", "1"], 10) + self.assertEqual(response.json(), {"0": 10, "1": 0}) + + def test_metricity_activity_data_invalid_days(self): + # Given + self.mock_no_metricity_user() # Other functions shouldn't be used. + + # When + url = reverse("api:bot:user-metricity-activity-data") + response = self.client.post( + url, + data=[0, 1], + QUERY_STRING="days=fifty", + ) + + # Then + self.assertEqual(response.status_code, 400) + self.metricity.total_messages_in_past_n_days.assert_not_called() + self.assertEqual(response.json(), {"days": ["This query parameter must be an integer."]}) + + def test_metricity_activity_data_no_days(self): + # Given + self.mock_no_metricity_user() # Other functions shouldn't be used. + + # When + url = reverse('api:bot:user-metricity-activity-data') + response = self.client.post( + url, + data=[0, 1], + ) + + # Then + self.assertEqual(response.status_code, 400) + self.metricity.total_messages_in_past_n_days.assert_not_called() + self.assertEqual(response.json(), {'days': ["This query parameter is required."]}) + + def test_metricity_activity_data_no_users(self): + # Given + self.mock_no_metricity_user() # Other functions shouldn't be used. + + # When + url = reverse('api:bot:user-metricity-activity-data') + response = self.client.post( + url, + QUERY_STRING="days=10", + ) + + # Then + self.assertEqual(response.status_code, 400) + self.metricity.total_messages_in_past_n_days.assert_not_called() + self.assertEqual(response.json(), ['Expected a list of items but got type "dict".']) + + def test_metricity_activity_data_invalid_users(self): + # Given + self.mock_no_metricity_user() # Other functions shouldn't be used. + + # When + url = reverse('api:bot:user-metricity-activity-data') + response = self.client.post( + url, + data=[123, 'username'], + QUERY_STRING="days=10", + ) + + # Then + self.assertEqual(response.status_code, 400) + self.metricity.total_messages_in_past_n_days.assert_not_called() + self.assertEqual(response.json(), {'1': ['A valid integer is required.']}) + def mock_metricity_user(self, joined_at, total_messages, total_blocks, top_channel_activity): patcher = patch("pydis_site.apps.api.viewsets.bot.user.Metricity") self.metricity = patcher.start() @@ -520,3 +604,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..abff8f55 100644 --- a/pydis_site/apps/api/tests/test_validators.py +++ b/pydis_site/apps/api/tests/test_validators.py @@ -1,11 +1,10 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from django.core.exceptions import ValidationError from django.test import TestCase -from ..models.bot.bot_setting import validate_bot_setting_name -from ..models.bot.offensive_message import future_date_validator -from ..models.utils import validate_embed +from pydis_site.apps.api.models.bot.bot_setting import validate_bot_setting_name +from pydis_site.apps.api.models.bot.offensive_message import future_date_validator REQUIRED_KEYS = ( @@ -22,238 +21,10 @@ 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)) + future_date_validator(datetime(3000, 1, 1, tzinfo=UTC)) def test_rejects_non_future_date(self): with self.assertRaises(ValidationError): - future_date_validator(datetime(1000, 1, 1, tzinfo=timezone.utc)) + future_date_validator(datetime(1000, 1, 1, tzinfo=UTC)) diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index b0ab545b..80d4edc2 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -1,12 +1,21 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import HealthcheckView, RulesView +from .views import ( + GitHubArtifactsView, + GitHubWebhookFilterView, + HealthcheckView, + RulesView, +) from .viewsets import ( + AocAccountLinkViewSet, + AocCompletionistBlockViewSet, BotSettingViewSet, + BumpedThreadViewSet, DeletedMessageViewSet, DocumentationLinkViewSet, FilterListViewSet, + FilterViewSet, InfractionViewSet, NominationViewSet, OffTopicChannelNameViewSet, @@ -19,14 +28,30 @@ from .viewsets import ( # https://www.django-rest-framework.org/api-guide/routers/#defaultrouter bot_router = DefaultRouter(trailing_slash=False) bot_router.register( - 'filter-lists', + 'filter/filter_lists', FilterListViewSet ) bot_router.register( + "aoc-account-links", + AocAccountLinkViewSet +) +bot_router.register( + "aoc-completionist-blocks", + AocCompletionistBlockViewSet +) +bot_router.register( + 'filter/filters', + FilterViewSet +) +bot_router.register( 'bot-settings', BotSettingViewSet ) bot_router.register( + 'bumped-threads', + BumpedThreadViewSet +) +bot_router.register( 'deleted-messages', DeletedMessageViewSet ) @@ -35,6 +60,10 @@ bot_router.register( DocumentationLinkViewSet ) bot_router.register( + 'filter-lists', + FilterListViewSet +) +bot_router.register( 'infractions', InfractionViewSet ) @@ -71,5 +100,15 @@ urlpatterns = ( # from django_hosts.resolvers import reverse path('bot/', include((bot_router.urls, 'api'), namespace='bot')), path('healthcheck', HealthcheckView.as_view(), name='healthcheck'), - path('rules', RulesView.as_view(), name='rules') + path('rules', RulesView.as_view(), name='rules'), + path( + 'github/artifact/<str:owner>/<str:repo>/<str:sha>/<str:action_name>/<str:artifact_name>', + GitHubArtifactsView.as_view(), + name="github-artifacts" + ), + path( + 'github/webhook-filter/<str:webhook_id>/<str:webhook_token>', + GitHubWebhookFilterView.as_view(), + name='github-webhook-filter' + ), ) diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 816463f6..1fa3efc2 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -1,7 +1,17 @@ +import json +import logging +import urllib.request +from collections.abc import Mapping +from http import HTTPStatus + +from rest_framework import status from rest_framework.exceptions import ParseError +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from . import github_utils + class HealthcheckView(APIView): """ @@ -34,12 +44,14 @@ class RulesView(APIView): ## Routes ### GET /rules - Returns a JSON array containing the server's rules: + Returns a JSON array containing the server's rules + and keywords relating to each rule. + Example response: >>> [ - ... "Eat candy.", - ... "Wake up at 4 AM.", - ... "Take your medicine." + ... ["Eat candy.", ["candy", "sweets"]], + ... ["Wake up at 4 AM.", ["wake_up", "early", "early_bird"]], + ... ["Take your medicine.", ["medicine", "health"]] ... ] Since some of the the rules require links, this view @@ -88,7 +100,7 @@ class RulesView(APIView): """ if target == 'html': return f'<a href="{link}">{description}</a>' - elif target == 'md': + elif target == 'md': # noqa: RET505 return f'[{description}]({link})' else: raise ValueError( @@ -96,7 +108,13 @@ class RulesView(APIView): ) # `format` here is the result format, we have a link format here instead. - def get(self, request, format=None): # noqa: D102,ANN001,ANN201 + def get(self, request, format=None): # noqa: ANN001, ANN201 + """ + Returns a list of our community rules coupled with their keywords. + + Each item in the returned list is a tuple with the rule as first item + and a list of keywords that match that rules as second item. + """ link_format = request.query_params.get('link_format', 'md') if link_format not in ('html', 'md'): raise ParseError( @@ -109,7 +127,7 @@ class RulesView(APIView): link_format ) discord_tos = self._format_link( - 'Terms Of Service', + 'Terms of Service', 'https://discordapp.com/terms', link_format ) @@ -121,34 +139,200 @@ class RulesView(APIView): return Response([ ( - f"Follow the {pydis_coc}." + f"Follow the {pydis_coc}.", + ["coc", "conduct", "code"] ), ( - f"Follow the {discord_community_guidelines} and {discord_tos}." + f"Follow the {discord_community_guidelines} and {discord_tos}.", + ["discord", "guidelines", "discord_tos"] ), ( - "Respect staff members and listen to their instructions." + "Respect staff members and listen to their instructions.", + ["respect", "staff", "instructions"] ), ( "Use English to the best of your ability. " - "Be polite if someone speaks English imperfectly." + "Be polite if someone speaks English imperfectly.", + ["english", "eng", "language"] ), ( - "Do not provide or request help on projects that may break laws, " - "breach terms of services, or are malicious or inappropriate." + "Do not provide or request help on projects that may violate terms of service, " + "or that may be deemed inappropriate, malicious, or illegal.", + ["infraction", "tos", "breach", "malicious", "inappropriate", "illegal"] ), ( - "Do not post unapproved advertising." + "Do not post unapproved advertising.", + ["ad", "ads", "advert", "advertising"] ), ( "Keep discussions relevant to the channel topic. " - "Each channel's description tells you the topic." + "Each channel's description tells you the topic.", + ["off-topic", "topic", "relevance"] ), ( "Do not help with ongoing exams. When helping with homework, " - "help people learn how to do the assignment without doing it for them." + "help people learn how to do the assignment without doing it for them.", + ["exam", "exams", "assignment", "assignments", "homework", "hw"] ), ( - "Do not offer or ask for paid work of any kind." + "Do not offer or ask for paid work of any kind.", + ["pay", "paid", "work", "money", "hire"] + ), + ( + "Do not copy and paste answers from ChatGPT or similar AI tools.", + ["gpt", "chatgpt", "gpt3", "ai"] ), ]) + + +class GitHubArtifactsView(APIView): + """ + Provides utilities for interacting with the GitHub API and obtaining action artifacts. + + ## Routes + ### GET /github/artifacts + Returns a download URL for the artifact requested. + + { + 'url': 'https://pipelines.actions.githubusercontent.com/...' + } + + ### Exceptions + In case of an error, the following body will be returned: + + { + "error_type": "<error class name>", + "error": "<error description>", + "requested_resource": "<owner>/<repo>/<sha>/<artifact_name>" + } + + ## Authentication + Does not require any authentication nor permissions. + """ + + authentication_classes = () + permission_classes = () + + def get( + self, + request: Request, + *, + owner: str, + repo: str, + sha: str, + action_name: str, + artifact_name: str + ) -> Response: + """Return a download URL for the requested artifact.""" + try: + url = github_utils.get_artifact(owner, repo, sha, action_name, artifact_name) + return Response({"url": url}) + except github_utils.ArtifactProcessingError as e: + return Response({ + "error_type": e.__class__.__name__, + "error": str(e), + "requested_resource": f"{owner}/{repo}/{sha}/{action_name}/{artifact_name}" + }, status=e.status) + + +class GitHubWebhookFilterView(APIView): + """ + Filters uninteresting events from webhooks sent by GitHub to Discord. + + ## Routes + ### POST /github/webhook-filter/:webhook_id/:webhook_token + Takes the GitHub webhook payload as the request body, documented on here: + https://docs.github.com/en/webhooks/webhook-events-and-payloads. The endpoint + will then determine whether the sent webhook event is of interest, + and if so, will forward it to Discord. The response from Discord is + then returned back to the client of this website, including the original + status code and headers (excluding `Content-Type`). + + ## Authentication + Does not require any authentication nor permissions on its own, however, + Discord will validate that the webhook originates from GitHub and respond + with a 403 forbidden error if not. + """ + + authentication_classes = () + permission_classes = () + logger = logging.getLogger(__name__ + ".GitHubWebhookFilterView") + + def post(self, request: Request, *, webhook_id: str, webhook_token: str) -> Response: + """Filter a webhook POST from GitHub before sending it to Discord.""" + sender = request.data.get('sender', {}) + sender_name = sender.get('login', '').lower() + event = request.headers.get('X-GitHub-Event', '').lower() + repository = request.data.get('repository', {}) + + is_coveralls = 'coveralls' in sender_name + is_github_bot = sender.get('type', '').lower() == 'bot' + is_sentry = 'sentry-io' in sender_name + is_dependabot_branch_deletion = ( + 'dependabot' in request.data.get('ref', '').lower() + and event == 'delete' + ) + is_bot_pr_approval = is_github_bot and event == 'pull_request_review' + is_empty_review = ( + request.data.get('review', {}).get('state', '').lower() == 'commented' + and event == 'pull_request_review' + and request.data.get('review', {}).get('body') is None + ) + is_black_non_main_push = ( + request.data.get('ref') != 'refs/heads/main' + and repository.get('name', '').lower() == 'black' + and repository.get('owner', {}).get('login', '').lower() == 'psf' + and event == 'push' + ) + + is_bot_payload = ( + is_coveralls + or (is_github_bot and not is_sentry) + or is_dependabot_branch_deletion + or is_bot_pr_approval + ) + is_noisy_user_action = is_empty_review + should_ignore = is_bot_payload or is_noisy_user_action or is_black_non_main_push + + if should_ignore: + return Response( + {'message': "Ignored by github-filter endpoint"}, + status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION, + ) + + (response_status, headers, body) = self.send_webhook( + webhook_id, webhook_token, request.data, dict(request.headers), + ) + headers.pop('Connection', None) + headers.pop('Content-Length', None) + return Response(data=body, headers=headers, status=response_status) + + def send_webhook( + self, + webhook_id: str, + webhook_token: str, + data: dict, + headers: Mapping[str, str], + ) -> tuple[int, dict[str, str], bytes]: + """Execute a webhook on Discord's GitHub webhook endpoint.""" + payload = json.dumps(data).encode() + headers.pop('Content-Length', None) + headers.pop('Content-Type', None) + headers.pop('Host', None) + request = urllib.request.Request( # noqa: S310 + f'https://discord.com/api/webhooks/{webhook_id}/{webhook_token}/github?wait=1', + data=payload, + headers={'Content-Type': 'application/json', **headers}, + ) + + try: + with urllib.request.urlopen(request) as response: # noqa: S310 + return (response.status, dict(response.getheaders()), response.read()) + except urllib.error.HTTPError as err: # pragma: no cover + if err.code == HTTPStatus.TOO_MANY_REQUESTS: + self.logger.warning( + "We are being rate limited by Discord! Scope: %s, reset-after: %s", + headers.get("X-RateLimit-Scope"), + headers.get("X-RateLimit-Reset-After"), + ) + return (err.code, dict(err.headers), err.fp.read()) diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index f133e77f..1dae9be1 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -1,12 +1,17 @@ # flake8: noqa from .bot import ( - FilterListViewSet, 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 84b87eab..33b65009 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -1,12 +1,18 @@ # flake8: noqa -from .filter_list import FilterListViewSet +from .filters import ( + FilterListViewSet, + FilterViewSet +) from .bot_setting import BotSettingViewSet +from .bumped_thread import BumpedThreadViewSet from .deleted_message import DeletedMessageViewSet 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..97efb63c --- /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,) + filterset_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..3cdc342d --- /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,) + filterset_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/filter_list.py b/pydis_site/apps/api/viewsets/bot/filter_list.py deleted file mode 100644 index 4b05acee..00000000 --- a/pydis_site/apps/api/viewsets/bot/filter_list.py +++ /dev/null @@ -1,98 +0,0 @@ -from rest_framework.decorators import action -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet - -from pydis_site.apps.api.models.bot.filter_list import FilterList -from pydis_site.apps.api.serializers import FilterListSerializer - - -class FilterListViewSet(ModelViewSet): - """ - View providing CRUD operations on items allowed or denied by our bot. - - ## Routes - ### GET /bot/filter-lists - Returns all filterlist items in the database. - - #### Response format - >>> [ - ... { - ... 'id': "2309268224", - ... 'created_at': "01-01-2020 ...", - ... 'updated_at': "01-01-2020 ...", - ... 'type': "file_format", - ... 'allowed': 'true', - ... 'content': ".jpeg", - ... 'comment': "Popular image format.", - ... }, - ... ... - ... ] - - #### Status codes - - 200: returned on success - - 401: returned if unauthenticated - - ### GET /bot/filter-lists/<id:int> - Returns a specific FilterList item from the database. - - #### Response format - >>> { - ... 'id': "2309268224", - ... 'created_at': "01-01-2020 ...", - ... 'updated_at': "01-01-2020 ...", - ... 'type': "file_format", - ... 'allowed': 'true', - ... 'content': ".jpeg", - ... 'comment': "Popular image format.", - ... } - - #### Status codes - - 200: returned on success - - 404: returned if the id was not found. - - ### GET /bot/filter-lists/get-types - Returns a list of valid list types that can be used in POST requests. - - #### Response format - >>> [ - ... ["GUILD_INVITE","Guild Invite"], - ... ["FILE_FORMAT","File Format"], - ... ["DOMAIN_NAME","Domain Name"], - ... ["FILTER_TOKEN","Filter Token"], - ... ["REDIRECT", "Redirect"] - ... ] - - #### Status codes - - 200: returned on success - - ### POST /bot/filter-lists - Adds a single FilterList item to the database. - - #### Request body - >>> { - ... 'type': str, - ... 'allowed': bool, - ... 'content': str, - ... 'comment': Optional[str], - ... } - - #### Status codes - - 201: returned on success - - 400: if one of the given fields is invalid - - ### DELETE /bot/filter-lists/<id:int> - Deletes the FilterList item with the given `id`. - - #### Status codes - - 204: returned on success - - 404: if a tag with the given `id` does not exist - """ - - serializer_class = FilterListSerializer - queryset = FilterList.objects.all() - - @action(detail=False, url_path='get-types', methods=["get"]) - def get_types(self, _: Request) -> Response: - """Get a list of all the types of FilterLists we support.""" - return Response(FilterList.FilterListType.choices) diff --git a/pydis_site/apps/api/viewsets/bot/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py new file mode 100644 index 00000000..9c9e8338 --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/filters.py @@ -0,0 +1,499 @@ +from rest_framework.viewsets import ModelViewSet + +from pydis_site.apps.api.models.bot.filters import ( # - Preserving the filter order + FilterList, + Filter +) +from pydis_site.apps.api.serializers import ( # - Preserving the filter order + FilterListSerializer, + FilterSerializer, +) + + +class FilterListViewSet(ModelViewSet): + """ + View providing GET/DELETE on lists of items allowed or denied by our bot. + + ## Routes + ### GET /bot/filter/filter_lists + Returns all FilterList items in the database. + + #### Response format + >>> [ + ... { + ... "id": 1, + ... "created_at": "2023-01-27T21:26:34.027293Z", + ... "updated_at": "2023-01-27T21:26:34.027308Z", + ... "name": "invite", + ... "list_type": 1, + ... "filters": [ + ... { + ... "id": 1, + ... "created_at": "2023-01-27T21:26:34.029539Z", + ... "updated_at": "2023-01-27T21:26:34.030532Z", + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_settings": None, + ... "filter_list": 1, + ... "settings": { + ... "bypass_roles": None, + ... "filter_dm": None, + ... "enabled": None, + ... "remove_context": None, + ... "send_alert": None, + ... "infraction_and_notification": { + ... "infraction_type": None, + ... "infraction_reason": None, + ... "infraction_duration": None, + ... "infraction_channel": None, + ... "dm_content": None, + ... "dm_embed": None + ... }, + ... "channel_scope": { + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None, + ... "enabled_categories": None + ... }, + ... "mentions": { + ... "guild_pings": None, + ... "dm_pings": None + ... } + ... } + ... }, + ... ... + ... ], + ... "settings": { + ... "bypass_roles": [ + ... "Helpers" + ... ], + ... "filter_dm": True, + ... "enabled": True, + ... "remove_context": True, + ... "send_alert": True, + ... "infraction_and_notification": { + ... "infraction_type": "NONE", + ... "infraction_reason": "", + ... "infraction_duration": "0.0", + ... "infraction_channel": 0, + ... "dm_content": "Per Rule 6, your invite link has been removed...", + ... "dm_embed": "" + ... }, + ... "channel_scope": { + ... "disabled_channels": [], + ... "disabled_categories": [ + ... "CODE JAM" + ... ], + ... "enabled_channels": [], + ... "enabled_categories": [] + ... }, + ... "mentions": { + ... "guild_pings": [ + ... "Moderators" + ... ], + ... "dm_pings": [] + ... } + ... } + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + - 401: returned if unauthenticated + + ### GET /bot/filter/filter_lists/<id:int> + Returns a specific FilterList item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "created_at": "2023-01-27T21:26:34.027293Z", + ... "updated_at": "2023-01-27T21:26:34.027308Z", + ... "name": "invite", + ... "list_type": 1, + ... "filters": [ + ... { + ... "id": 1, + ... "created_at": "2023-01-27T21:26:34.029539Z", + ... "updated_at": "2023-01-27T21:26:34.030532Z", + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_settings": None, + ... "filter_list": 1, + ... "settings": { + ... "bypass_roles": None, + ... "filter_dm": None, + ... "enabled": None, + ... "remove_context": None, + ... "send_alert": None, + ... "infraction_and_notification": { + ... "infraction_type": None, + ... "infraction_reason": None, + ... "infraction_duration": None, + ... "infraction_channel": None, + ... "dm_content": None, + ... "dm_embed": None + ... }, + ... "channel_scope": { + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None, + ... "enabled_categories": None + ... }, + ... "mentions": { + ... "guild_pings": None, + ... "dm_pings": None + ... } + ... } + ... }, + ... ... + ... ], + ... "settings": { + ... "bypass_roles": [ + ... "Helpers" + ... ], + ... "filter_dm": True, + ... "enabled": True, + ... "remove_context": True, + ... "send_alert": True, + ... "infraction_and_notification": { + ... "infraction_type": "NONE", + ... "infraction_reason": "", + ... "infraction_duration": "0.0", + ... "infraction_channel": 0, + ... "dm_content": "Per Rule 6, your invite link has been removed...", + ... "dm_embed": "" + ... }, + ... "channel_scope": { + ... "disabled_channels": [], + ... "disabled_categories": [ + ... "CODE JAM" + ... ], + ... "enabled_channels": [], + ... "enabled_categories": [] + ... }, + ... "mentions": { + ... "guild_pings": [ + ... "Moderators" + ... ], + ... "dm_pings": [] + ... } + ... } + ... } + + #### Status codes + - 200: returned on success + - 404: returned if the id was not found. + + ### POST /bot/filter/filter_lists + Adds a single FilterList item to the database. + + #### Request body + >>> { + ... "name": "invite", + ... "list_type": 1, + ... "bypass_roles": [ + ... "Helpers" + ... ], + ... "filter_dm": True, + ... "enabled": True, + ... "remove_context": True, + ... "send_alert": True, + ... "infraction_type": "NONE", + ... "infraction_reason": "", + ... "infraction_duration": "0.0", + ... "infraction_channel": 0, + ... "dm_content": "Per Rule 6, your invite link has been removed...", + ... "dm_embed": "", + ... "disabled_channels": [], + ... "disabled_categories": [ + ... "CODE JAM" + ... ], + ... "enabled_channels": [], + ... "enabled_categories": [] + ... "guild_pings": [ + ... "Moderators" + ... ], + ... "dm_pings": [] + ... } + + #### Status codes + - 201: returned on success + - 400: if one of the given fields is invalid + + ### PATCH /bot/filter/filter_lists/<id:int> + Updates a specific FilterList item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "created_at": "2023-01-27T21:26:34.027293Z", + ... "updated_at": "2023-01-27T21:26:34.027308Z", + ... "name": "invite", + ... "list_type": 1, + ... "filters": [ + ... { + ... "id": 1, + ... "created_at": "2023-01-27T21:26:34.029539Z", + ... "updated_at": "2023-01-27T21:26:34.030532Z", + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_settings": None, + ... "filter_list": 1, + ... "settings": { + ... "bypass_roles": None, + ... "filter_dm": None, + ... "enabled": None, + ... "remove_context": None, + ... "send_alert": None, + ... "infraction_and_notification": { + ... "infraction_type": None, + ... "infraction_reason": None, + ... "infraction_duration": None, + ... "infraction_channel": None, + ... "dm_content": None, + ... "dm_embed": None + ... }, + ... "channel_scope": { + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None, + ... "enabled_categories": None + ... }, + ... "mentions": { + ... "guild_pings": None, + ... "dm_pings": None + ... } + ... } + ... }, + ... ... + ... ], + ... "settings": { + ... "bypass_roles": [ + ... "Helpers" + ... ], + ... "filter_dm": True, + ... "enabled": True, + ... "remove_context": True, + ... "send_alert": True, + ... "infraction_and_notification": { + ... "infraction_type": "NONE", + ... "infraction_reason": "", + ... "infraction_duration": "0.0", + ... "infraction_channel": 0, + ... "dm_content": "Per Rule 6, your invite link has been removed...", + ... "dm_embed": "" + ... }, + ... "channel_scope": { + ... "disabled_channels": [], + ... "disabled_categories": [ + ... "CODE JAM" + ... ], + ... "enabled_channels": [], + ... "enabled_categories": [] + ... }, + ... "mentions": { + ... "guild_pings": [ + ... "Moderators" + ... ], + ... "dm_pings": [] + ... } + ... } + ... } + + #### Status codes + - 200: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/filter/filter_lists/<id:int> + Deletes the FilterList item with the given `id`. + + #### Status codes + - 204: returned on success + - 404: if a FilterList with the given `id` does not exist + """ + + serializer_class = FilterListSerializer + queryset = FilterList.objects.all() + + +class FilterViewSet(ModelViewSet): + """ + View providing CRUD operations on items allowed or denied by our bot. + + ## Routes + ### GET /bot/filter/filters + Returns all Filter items in the database. + + #### Response format + >>> [ + ... { + ... "id": 1, + ... "created_at": "2023-01-27T21:26:34.029539Z", + ... "updated_at": "2023-01-27T21:26:34.030532Z", + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_settings": None, + ... "filter_list": 1, + ... "settings": { + ... "bypass_roles": None, + ... "filter_dm": None, + ... "enabled": None, + ... "remove_context": None, + ... "send_alert": None, + ... "infraction_and_notification": { + ... "infraction_type": None, + ... "infraction_reason": None, + ... "infraction_duration": None, + ... "infraction_channel": None, + ... "dm_content": None, + ... "dm_embed": None + ... }, + ... "channel_scope": { + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None, + ... "enabled_categories": None + ... }, + ... "mentions": { + ... "guild_pings": None, + ... "dm_pings": None + ... } + ... } + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + - 401: returned if unauthenticated + + ### GET /bot/filter/filters/<id:int> + Returns a specific Filter item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "created_at": "2023-01-27T21:26:34.029539Z", + ... "updated_at": "2023-01-27T21:26:34.030532Z", + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_settings": None, + ... "filter_list": 1, + ... "settings": { + ... "bypass_roles": None, + ... "filter_dm": None, + ... "enabled": None, + ... "remove_context": None, + ... "send_alert": None, + ... "infraction_and_notification": { + ... "infraction_type": None, + ... "infraction_reason": None, + ... "infraction_duration": None, + ... "infraction_channel": None, + ... "dm_content": None, + ... "dm_embed": None + ... }, + ... "channel_scope": { + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None, + ... "enabled_categories": None + ... }, + ... "mentions": { + ... "guild_pings": None, + ... "dm_pings": None + ... } + ... } + ... } + + #### Status codes + - 200: returned on success + - 404: returned if the id was not found. + + ### POST /bot/filter/filters + Adds a single Filter item to the database. + + #### Request body + >>> { + ... "filter_list": 1, + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_settings": None, + ... "bypass_roles": None, + ... "filter_dm": None, + ... "enabled": False, + ... "remove_context": None, + ... "send_alert": None, + ... "infraction_type": None, + ... "infraction_reason": None, + ... "infraction_duration": None, + ... "infraction_channel": None, + ... "dm_content": None, + ... "dm_embed": None + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None, + ... "enabled_categories": None + ... "guild_pings": None, + ... "dm_pings": None + ... } + + #### Status codes + - 201: returned on success + - 400: if one of the given fields is invalid + + ### PATCH /bot/filter/filters/<id:int> + Updates a specific Filter item from the database. + + #### Response format + >>> { + ... "id": 1, + ... "created_at": "2023-01-27T21:26:34.029539Z", + ... "updated_at": "2023-01-27T21:26:34.030532Z", + ... "content": "267624335836053506", + ... "description": "Python Discord", + ... "additional_settings": None, + ... "filter_list": 1, + ... "settings": { + ... "bypass_roles": None, + ... "filter_dm": None, + ... "enabled": None, + ... "remove_context": None, + ... "send_alert": None, + ... "infraction_and_notification": { + ... "infraction_type": None, + ... "infraction_reason": None, + ... "infraction_duration": None, + ... "infraction_channel": None, + ... "dm_content": None, + ... "dm_embed": None + ... }, + ... "channel_scope": { + ... "disabled_channels": None, + ... "disabled_categories": None, + ... "enabled_channels": None, + ... "enabled_categories": None + ... }, + ... "mentions": { + ... "guild_pings": None, + ... "dm_pings": None + ... } + ... } + ... } + + #### Status codes + - 200: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/filter/filters/<id:int> + Deletes the Filter item with the given `id`. + + #### Status codes + - 204: returned on success + - 404: if a Filter with the given `id` does not exist + """ + + serializer_class = FilterSerializer + queryset = Filter.objects.all() diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index 8a48ed1f..09c05a74 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -1,5 +1,6 @@ -from datetime import datetime +import datetime +from django.db import IntegrityError from django.db.models import QuerySet from django.http.request import HttpRequest from django_filters.rest_framework import DjangoFilterBackend @@ -71,7 +72,8 @@ class InfractionViewSet( ... 'type': 'ban', ... 'reason': 'He terk my jerb!', ... 'hidden': True, - ... 'dm_sent': True + ... 'dm_sent': True, + ... 'jump_url': '<discord message link>' ... } ... ] @@ -102,7 +104,8 @@ class InfractionViewSet( ... 'type': 'ban', ... 'reason': 'He terk my jerb!', ... 'user': 172395097705414656, - ... 'dm_sent': False + ... 'dm_sent': False, + ... 'jump_url': '<discord message link>' ... } #### Response format @@ -137,7 +140,7 @@ class InfractionViewSet( #### Status codes - 204: returned on success - - 404: if a infraction with the given `id` does not exist + - 404: if an infraction with the given `id` does not exist ### Expanded routes All routes support expansion of `user` and `actor` in responses. To use an expanded route, @@ -152,16 +155,11 @@ class InfractionViewSet( queryset = Infraction.objects.all() pagination_class = LimitOffsetPaginationExtended filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter) - filter_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type') + filterset_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type') search_fields = ('$reason',) - frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden') def partial_update(self, request: HttpRequest, *_args, **_kwargs) -> Response: """Method that handles the nuts and bolts of updating an Infraction.""" - for field in request.data: - if field in self.frozen_fields: - raise ValidationError({field: ['This field cannot be updated.']}) - instance = self.get_object() serializer = self.get_serializer(instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) @@ -183,20 +181,22 @@ 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.datetime.fromisoformat(filter_expires_after) except ValueError: raise ValidationError({'expires_after': ['failed to convert to datetime']}) + additional_filters['expires_at__gte'] = expires_after_parsed.replace( + tzinfo=datetime.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.datetime.fromisoformat(filter_expires_before) except ValueError: raise ValidationError({'expires_before': ['failed to convert to datetime']}) + additional_filters['expires_at__lte'] = expires_before_parsed.replace( + tzinfo=datetime.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 +271,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/nomination.py b/pydis_site/apps/api/viewsets/bot/nomination.py index 144daab0..953513e0 100644 --- a/pydis_site/apps/api/viewsets/bot/nomination.py +++ b/pydis_site/apps/api/viewsets/bot/nomination.py @@ -172,8 +172,7 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge serializer_class = NominationSerializer queryset = Nomination.objects.all() filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter) - filter_fields = ('user__id', 'active') - frozen_fields = ('id', 'inserted_at', 'user', 'ended_at') + filterset_fields = ('user__id', 'active') frozen_on_create = ('ended_at', 'end_reason', 'active', 'inserted_at', 'reviewed') def create(self, request: HttpRequest, *args, **kwargs) -> Response: @@ -238,10 +237,6 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge Called by the Django Rest Framework in response to the corresponding HTTP request. """ - for field in request.data: - if field in self.frozen_fields: - raise ValidationError({field: ['This field cannot be updated.']}) - instance = self.get_object() serializer = self.get_serializer(instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) @@ -273,6 +268,11 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge {'reviewed': ['This field cannot be set while you are ending a nomination.']} ) + if 'thread_id' in request.data: + raise ValidationError( + {'thread_id': ['This field cannot be set when ending a nomination.']} + ) + instance.ended_at = timezone.now() elif 'active' in data: @@ -281,13 +281,15 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge {'active': ['This field can only be used to end a nomination']} ) - # This is actually covered, but for some reason coverage don't think so. - elif 'reviewed' in request.data: # pragma: no cover - # 4. We are altering the reviewed state of the nomination. - if not instance.active: - raise ValidationError( - {'reviewed': ['This field cannot be set if the nomination is inactive.']} - ) + elif not instance.active and 'reviewed' in request.data: + raise ValidationError( + {'reviewed': ['This field cannot be set if the nomination is inactive.']} + ) + + elif not instance.active and 'thread_id' in request.data: + raise ValidationError( + {'thread_id': ['This field cannot be set if the nomination is inactive.']} + ) if 'reason' in request.data: if 'actor' not in request.data: 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..1774004c 100644 --- a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py +++ b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py @@ -85,10 +85,9 @@ class OffTopicChannelNameViewSet(ModelViewSet): serializer.save() return Response(create_data, status=HTTP_201_CREATED) - else: - raise ParseError(detail={ - 'name': ["This query parameter is required."] - }) + raise ParseError(detail={ + 'name': ["This query parameter is required."] + }) def list(self, request: Request, *args, **kwargs) -> Response: """ @@ -108,7 +107,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 +132,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/offensive_message.py b/pydis_site/apps/api/viewsets/bot/offensive_message.py index 54cb3a38..fc8837e0 100644 --- a/pydis_site/apps/api/viewsets/bot/offensive_message.py +++ b/pydis_site/apps/api/viewsets/bot/offensive_message.py @@ -1,6 +1,7 @@ from rest_framework.mixins import ( CreateModelMixin, DestroyModelMixin, + UpdateModelMixin, ListModelMixin ) from rest_framework.viewsets import GenericViewSet @@ -10,7 +11,7 @@ from pydis_site.apps.api.serializers import OffensiveMessageSerializer class OffensiveMessageViewSet( - CreateModelMixin, ListModelMixin, DestroyModelMixin, GenericViewSet + CreateModelMixin, ListModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet ): """ View providing CRUD access to offensive messages. @@ -46,6 +47,16 @@ class OffensiveMessageViewSet( - 201: returned on success - 400: if the body format is invalid + ### PATCH /bot/offensive-messages/<id:int> + Perform a partial update of the offensive message with the given `id`. + Intended to allow rescheduling the deletion date in case the bot's attempt + to delete the message failed due to another error than the message already + being deleted. + + #### Status codes + - 200: returned on success + - 404: if a offensive message object with the given `id` does not exist + ### DELETE /bot/offensive-messages/<id:int> Delete the offensive message object with the given `id`. diff --git a/pydis_site/apps/api/viewsets/bot/reminder.py b/pydis_site/apps/api/viewsets/bot/reminder.py index 78d7cb3b..5f997052 100644 --- a/pydis_site/apps/api/viewsets/bot/reminder.py +++ b/pydis_site/apps/api/viewsets/bot/reminder.py @@ -125,4 +125,4 @@ class ReminderViewSet( serializer_class = ReminderSerializer queryset = Reminder.objects.prefetch_related('author') filter_backends = (DjangoFilterBackend, SearchFilter) - filter_fields = ('active', 'author__id') + filterset_fields = ('active', 'author__id') diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py index a867a80f..77378336 100644 --- a/pydis_site/apps/api/viewsets/bot/user.py +++ b/pydis_site/apps/api/viewsets/bot/user.py @@ -1,9 +1,10 @@ -import typing from collections import OrderedDict from django.db.models import Q -from rest_framework import status +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import fields, status from rest_framework.decorators import action +from rest_framework.exceptions import ParseError from rest_framework.pagination import PageNumberPagination from rest_framework.request import Request from rest_framework.response import Response @@ -22,14 +23,14 @@ class UserListPagination(PageNumberPagination): page_size = 2500 page_size_query_param = "page_size" - def get_next_page_number(self) -> typing.Optional[int]: + def get_next_page_number(self) -> int | None: """Get the next page number.""" if not self.page.has_next(): return None page_number = self.page.next_page_number() return page_number - def get_previous_page_number(self) -> typing.Optional[int]: + def get_previous_page_number(self) -> int | None: """Get the previous page number.""" if not self.page.has_previous(): return None @@ -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 @@ -135,6 +138,29 @@ class UserViewSet(ModelViewSet): - 200: returned on success - 404: if a user with the given `snowflake` could not be found + ### POST /bot/users/metricity_activity_data + Returns a mapping of user ID to message count in a given period for + the given user IDs. + + #### Required Query Parameters + - days: how many days into the past to count message from. + + #### Request Format + >>> [ + ... 409107086526644234, + ... 493839819168808962 + ... ] + + #### Response format + >>> { + ... "409107086526644234": 54, + ... "493839819168808962": 0 + ... } + + #### Status codes + - 200: returned on success + - 400: if request body or query parameters were missing or invalid + ### POST /bot/users Adds a single or multiple new users. The roles attached to the user(s) must be roles known by the site. @@ -233,6 +259,8 @@ class UserViewSet(ModelViewSet): serializer_class = UserSerializer queryset = User.objects.all().order_by("id") pagination_class = UserListPagination + filter_backends = (DjangoFilterBackend,) + filterset_fields = ('name', 'discriminator') def get_serializer(self, *args, **kwargs) -> ModelSerializer: """Set Serializer many attribute to True if request body contains a list.""" @@ -257,7 +285,7 @@ class UserViewSet(ModelViewSet): return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=True) - def metricity_data(self, request: Request, pk: str = None) -> Response: + def metricity_data(self, request: Request, pk: str | None = None) -> Response: """Request handler for metricity_data endpoint.""" user = self.get_object() @@ -280,7 +308,7 @@ class UserViewSet(ModelViewSet): status=status.HTTP_404_NOT_FOUND) @action(detail=True) - def metricity_review_data(self, request: Request, pk: str = None) -> Response: + def metricity_review_data(self, request: Request, pk: str | None = None) -> Response: """Request handler for metricity_review_data endpoint.""" user = self.get_object() @@ -293,3 +321,34 @@ class UserViewSet(ModelViewSet): except NotFoundError: return Response(dict(detail="User not found in metricity"), status=status.HTTP_404_NOT_FOUND) + + @action(detail=False, methods=["POST"]) + def metricity_activity_data(self, request: Request) -> Response: + """Request handler for metricity_activity_data endpoint.""" + if "days" in request.query_params: + try: + days = int(request.query_params["days"]) + except ValueError: + raise ParseError(detail={ + "days": ["This query parameter must be an integer."] + }) + else: + raise ParseError(detail={ + "days": ["This query parameter is required."] + }) + + user_id_list_validator = fields.ListField( + child=fields.IntegerField(min_value=0), + allow_empty=False + ) + user_ids = [ + str(user_id) for user_id in + user_id_list_validator.run_validation(request.data) + ] + + with Metricity() as metricity: + data = metricity.total_messages_in_past_n_days(user_ids, days) + + default_data = {user_id: 0 for user_id in user_ids} + response_data = default_data | dict(data) + return Response(response_data, status=status.HTTP_200_OK) 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/0001_add_tags.py b/pydis_site/apps/content/migrations/0001_add_tags.py new file mode 100644 index 00000000..2c31e4c1 --- /dev/null +++ b/pydis_site/apps/content/migrations/0001_add_tags.py @@ -0,0 +1,35 @@ +# Generated by Django 4.0.6 on 2022-08-23 09:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Commit', + fields=[ + ('sha', models.CharField(help_text='The SHA hash of this commit.', max_length=40, primary_key=True, serialize=False)), + ('message', models.TextField(help_text='The commit message.')), + ('date', models.DateTimeField(help_text='The date and time the commit was created.')), + ('authors', models.TextField(help_text='The person(s) who created the commit. This is a serialized JSON object. Refer to the GitHub documentation on the commit endpoint (schema/commit.author & schema/commit.committer) for more info. https://docs.github.com/en/rest/commits/commits#get-a-commit')), + ], + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('last_updated', models.DateTimeField(auto_now=True, help_text='The date and time this data was last fetched.')), + ('sha', models.CharField(help_text="The tag's hash, as calculated by GitHub.", max_length=40)), + ('name', models.CharField(help_text="The tag's name.", max_length=50, primary_key=True, serialize=False)), + ('group', models.CharField(help_text='The group the tag belongs to.', max_length=50, null=True)), + ('body', models.TextField(help_text='The content of the tag.')), + ('last_commit', models.ForeignKey(help_text='The commit this file was last touched in.', null=True, on_delete=django.db.models.deletion.CASCADE, to='content.commit')), + ], + ), + ] diff --git a/pydis_site/apps/content/models/__init__.py b/pydis_site/apps/content/models/__init__.py new file mode 100644 index 00000000..69c48962 --- /dev/null +++ b/pydis_site/apps/content/models/__init__.py @@ -0,0 +1,4 @@ +from .commit import Commit +from .tag import Tag + +__all__ = ["Commit", "Tag"] diff --git a/pydis_site/apps/content/models/commit.py b/pydis_site/apps/content/models/commit.py new file mode 100644 index 00000000..4d8bc552 --- /dev/null +++ b/pydis_site/apps/content/models/commit.py @@ -0,0 +1,38 @@ +import collections.abc +import json + +from django.db import models + + +class Commit(models.Model): + """A git commit from the Python Discord Bot project.""" + + URL_BASE = "https://github.com/python-discord/bot/commit/" + + sha = models.CharField( + help_text="The SHA hash of this commit.", + primary_key=True, + max_length=40, + ) + message = models.TextField(help_text="The commit message.") + date = models.DateTimeField(help_text="The date and time the commit was created.") + authors = models.TextField(help_text=( + "The person(s) who created the commit. This is a serialized JSON object. " + "Refer to the GitHub documentation on the commit endpoint " + "(schema/commit.author & schema/commit.committer) for more info. " + "https://docs.github.com/en/rest/commits/commits#get-a-commit" + )) + + @property + def url(self) -> str: + """The URL to the commit on GitHub.""" + return self.URL_BASE + self.sha + + def lines(self) -> collections.abc.Iterable[str]: + """Return each line in the commit message.""" + yield from self.message.split("\n") + + def format_authors(self) -> collections.abc.Iterable[str]: + """Return a nice representation of the author(s)' name and email.""" + for author in json.loads(self.authors): + yield f"{author['name']} <{author['email']}>" diff --git a/pydis_site/apps/content/models/tag.py b/pydis_site/apps/content/models/tag.py new file mode 100644 index 00000000..a28663c5 --- /dev/null +++ b/pydis_site/apps/content/models/tag.py @@ -0,0 +1,44 @@ +from django.db import models + +from .commit import Commit + + +class Tag(models.Model): + """A tag from the python-discord bot repository.""" + + URL_BASE = "https://github.com/python-discord/bot/tree/main/bot/resources/tags" + + last_updated = models.DateTimeField( + help_text="The date and time this data was last fetched.", + auto_now=True, + ) + sha = models.CharField( + help_text="The tag's hash, as calculated by GitHub.", + max_length=40, + ) + last_commit = models.ForeignKey( + Commit, + help_text="The commit this file was last touched in.", + null=True, + on_delete=models.CASCADE, + ) + name = models.CharField( + help_text="The tag's name.", + primary_key=True, + max_length=50, + ) + group = models.CharField( + help_text="The group the tag belongs to.", + null=True, + max_length=50, + ) + body = models.TextField(help_text="The content of the tag.") + + @property + def url(self) -> str: + """Get the URL of the tag on GitHub.""" + url = Tag.URL_BASE + if self.group: + url += f"/{self.group}" + url += f"/{self.name}.md" + return url diff --git a/pydis_site/apps/content/resources/frequently-asked-questions.md b/pydis_site/apps/content/resources/frequently-asked-questions.md index 1c9c3f6d..bef1f1ea 100644 --- a/pydis_site/apps/content/resources/frequently-asked-questions.md +++ b/pydis_site/apps/content/resources/frequently-asked-questions.md @@ -75,12 +75,6 @@ If you have any questions about how to contribute, drop by the `#dev-contrib` ch ## Server Specific Questions -#### **Q: Why are the help channels named after elements/food?** - -We want to keep the help channels uniquely named with it being somewhat easy to remember, so we decided on elements/food. `#help-1` doesn't work as well as `help-carbon` (`help-strawberry`). -If we had them numbered, they would quickly move out of order and possibly cause confusion for newer members. - - #### **Q: Why can't I upload a specific file type?** The only file types that we allow on this server are those that Discord supports a native preview for. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/asking-good-questions.md b/pydis_site/apps/content/resources/guides/pydis-guides/asking-good-questions.md index 971989a9..b08ba7c6 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/asking-good-questions.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/asking-good-questions.md @@ -26,7 +26,7 @@ If none of the above steps help you or you're not sure how to do some of the abo # A Good Question -When you're ready to ask a question, there's a few things you should have to hand before forming a query. +When you're ready to ask a question, there are a few things you should have to hand before forming a query. * A code example that illustrates your problem * If possible, make this a minimal example rather than an entire application 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 fd322d08..5dc6408c 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,11 +19,7 @@ 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 has-background-white"> @@ -46,11 +42,7 @@ 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"> @@ -73,11 +65,7 @@ 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"> @@ -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](#3-read-our-contributing-guidelines). + +If you are here looking for the answer to a specific question, check out the sub-articles in the top right of the page to see a list of our guides. + +**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. + +[**Read our Contributing Guidelines here.**](./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. + +[**Read our Style Guide here.**](./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. + +[**Find out how to write a good issue here.**](./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](./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-extended-configuration-options.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md new file mode 100644 index 00000000..f5425d88 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot-extended-configuration-options.md @@ -0,0 +1,191 @@ +--- +title: Extended options for configuring Bot +description: A guide that lists all extended/optional options to configure the bot further. +icon: fab fa-github +toc: 3 +--- + +## Manual constants configuration + +Reading this means that you're ready for a bit of manual labour. +If for some reason you've missed the automatic server setup section, you can read about it in the bot contributing guide [here](../bot.md#envserver) + +To configure the bot manually, you will **only** need to set inside the `.env.server` file the values for the channels, roles, categories, etc. +that are used by the component you are developing. + +For example, if we're testing a feature that only needs the `announcements` channel: + +`constants.py` + +```py + +class EnvConfig: + # Defines from where & how Pydantic will be looking for env variables + ... + +class _Channels(EnvConfig): + + EnvConfig.Config.env_prefix = "channels_" + + announcements = 1079790565794779156 + changelog = 1077877318564991006 + +# Instantiate the class & load the configuration +Channels = _Channels() +``` + +`.env.server` file: + +```text +# .env.server + +channels_announcements=1077875228002234398 +``` + +When you launch your bot, `pydantic` will load up the server constants from the `.env.server` file if they exist. + +Each constants class will define its own prefix, which will make `pydantic` look for variables that will look like `{{env_prefix}}{{attribute_name}}` in the environment files + +In our example, this will imply that pydantic will look for both `channels_announcements` and `channels_changelog` in the `.env.server` file. + +As you can see here, only `channels_announcements` has been defined in the `.env.server` file since it's the only one needed, which will tell `pydantic` +to use the value **1077875228002234398** for the `announcements` attribute instead of the default **1079790565794779156**, and use the default value for the `changelog` attribute + +```python +>>> Channels.announcements +1077875228002234398 +>>> Channels.changelong +1077877318564991006 +``` + +See [here](../obtaining-discord-ids) for help with obtaining Discord IDs. + +If you wish to set all values in your `env.server` for your testing server, you need to set **all** the ones prefixed with: + +* `guild_` +* `categories_` +* `channels_` +* `roles_` +* `webhooks_` +* `emojis_` + +## Working with the help forum + +**Note**: This is only required when you're not configuring the bot [automatically](#automatic-configuration) + +If you will be working on a feature that includes the Python help forum, you will need to use `Forum Channels`. + +Forum channels cannot be included in a template, which is why this needs to be done by hand for the time being. + +To activate forum channels, your Discord server needs to have the community feature. +If that's not the case already, here are the steps required to do it: + +1. Go to server settings +2. Scroll down to the `COMMUNITY` section and click on `Enable Community` +3. Click on `Get Started` and fill out the necessary info + +Once the previous steps are done, all that is left is to: + +1. Create a new channel +2. Choose the `Forum` type +3. [Copy its ID](../obtaining-discord-ids#channel-id) +4. Add the following line to the `.env.server` file: `channels_python_help={newly_created_forum_channel_id}` + +--- + +We understand this is tedious which is why we **heavily recommend** using the [automatic configuration setup](../bot.md#automatic-configuration) + +--- + +## Reloading parts of the bot +If you make changes to an extension, you might not need to restart the entire bot for the changes to take effect. The command `!ext reload <extension_name>` re-imports the files associated with the extension. +Invoke `!ext list` for a full list of the available extensions. In this bot in particular, cogs are defined inside extensions. + +Note that if you changed code that is not associated with a particular extension, such as utilities, converters, and constants, you will need to restart the bot. + +## Adding new statistics + +Details on how to add new statistics can be found on the [statistic infrastructure page](https://blog.pythondiscord.com/statistics-infrastructure). +We are always open to more statistics so add as many as you can! + +--- + +## With the Bot Running Locally +The advantage of this method is that you can run the bot's code in your preferred editor, with debugger and all, while keeping all the setup of the bot's various dependencies inside Docker. + +* Append the following line to your `.env` file: `API_KEYS_SITE_API=badbot13m0n8f570f942013fc818f234916ca531`. +* In your `.env.server` file, set `urls_site_api="http://localhost:8000/api"`. If you wish to keep using `http://web:8000/api`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. +* To work with snekbox, set `urls_snekbox_eval_api="http://localhost:8060/eval"`. + + +You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: + +* `docker compose up web` to start the site container. This is required. +* `docker compose up snekbox` to start the snekbox container. You only need this if you're planning on working on the snekbox cog. +* `docker compose up redis` to start the Redis container. You only need this if you're not using fakeredis. For more info refer to [Working with Redis](#optional-working-with-redis). + +You can start several services together: `docker compose up web snekbox redis`. + +### Setting Up a Development Environment + +With at least the site running in Docker already (see the previous section on how to start services separately), you can now start the bot locally through the command line, or through your preferred IDE. +<div class="card"> + <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-fw fa-angle-down title is-5" aria-hidden="true"></i> + </span> + </button> + <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> + <li>Through the command line, inside the bot directory, with either <code>poetry run task start</code>, or directly <code>python -m bot</code>.</li> + <li>If using PyCharm, enter <code>Edit Configurations</code> and set everything according to this image: <img src="/static/images/content/contributing/pycharm_run_module.png"></li> + <li>If using Visual Studio Code, set the interpreter to the poetry environment you created. In <code>launch.json</code> create a new Python configuration, and set the name of the program to be run to <code>bot</code>. VSC will correctly run it as a module.</li> + </ul> + </div> + </div> +</div> +<br> + +### With More Things Running Locally +You can run additional services on the host, but this guide won't go over how to install and start them in this way. +If possible, prefer to start the services through Docker to replicate the production environment as much as possible. + +The site, however, is a mandatory service for the bot. +Refer to the [previous section](#with-the-bot-running-locally) and the [site contributing guide](../site) to learn how to start it on the host, in which case you will need to change `urls.site` in `.env.server` to wherever the site is being hosted. + +--- + +### Starting Redis Using Other Methods +You can run your own instance of Redis, but in that case you will need to correctly set `redis_host` and `redis_port` in your `.env.server` file and the `REDIS_PASSWORD` in the `.env` file. +**Note**: The previously mentioned variables **SHOULD NOT** be overriden or changed in `constants.py` + +--- + +## Working with Metricity +[Metricity](https://github.com/python-discord/metricity) is our home-grown bot for collecting metrics on activity within the server, such as what users are present, and IDs of the messages they've sent. +Certain features in the Python bot rely on querying the Metricity database for information such as the number of messages a user has sent, most notably the voice verification system. + +If you wish to work on a feature that relies on Metricity, for your convenience we've made the process of using it relatively painless with Docker: Enter the `.env` file you've written for the Python bot, and append the line `USE_METRICITY=true`. +Note that if you don't need Metricity, there's no reason to have it enabled as it is just unnecessary overhead. + +To make the Metricity bot work with your test server, you will need to override its configurations similarly to the Python bot. +You can see the various configurations in [the Metricity repo](https://github.com/python-discord/metricity), but the bare minimum is the guild ID setting. +In your local version of the Python bot repo, create a file called `metricity-config.toml` and insert the following lines: +```yaml +[bot] +guild_id = replace_with_your_guild_id +``` +To properly replicate production behavior, set the `staff_role_id`, `staff_categories`, and `ignore_categories` fields as well. + +Now, `docker compose up` will also start Metricity. + +If you want to run the bot locally, you can run `docker compose up metricity` instead. + +--- + +## Working with bot moderation logs +To be able to view moderation-related logs published by the bot to site, you will need to set `urls_site_logs_view=http://localhost:8000/staff/bot/logs` in your `.env.server`. +This will work in both Docker and locally. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md index 2aa10aa3..f54ee664 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/bot.md @@ -5,44 +5,49 @@ icon: fab fa-github toc: 3 --- The purpose of this guide is to get you a running local version of [the Python bot](https://github.com/python-discord/bot). +You should have already forked the repository and cloned it to your local machine. If not, check out our [detailed walkthrough](../#1-fork-and-clone-the-repo). + This page will focus on the quickest steps one can take, with mentions of alternatives afterwards. -### 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). +--- +## Setting up the project + +### Setup Project Dependencies +Below are the dependencies you **must** have installed to get started with the bot. + +1. Make sure you have [Python 3.11](https://www.python.org/downloads/) installed. It helps if it is your system's default Python version. +1. [Install Poetry](https://github.com/python-poetry/poetry#installation). +1. [Install the project's dependencies](../installing-project-dependencies). +1. Docker. <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-title subtitle is-6 my-2 ml-2">Getting started with Docker</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"> - <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> + The requirements for Docker are: + <ul> + <li><a href="https://docs.docker.com/install">Docker CE</a></li> + <li>Docker Compose. If you're using macOS and Windows, this already comes bundled with the previous installation. Otherwise, you can download it either from the <a href="https://docs.docker.com/compose/install">website</a>, or by running <code>pip install docker-compose</code>.</li> + </ul> + <p class="notification is-warning">If you get any Docker related errors, reference the <a href="../docker#possible-issues">Possible Issue</a> section of the Docker page.</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. - ---- - ### Set Up a Test Server The Python bot is tightly coupled with the Python Discord server, so to have a functional version of the bot you need a server with channels it can use. It's possible to set the bot to use a single channel for all cogs, but that will cause extreme spam and will be difficult to work with. You can start your own server and set up channels as you see fit, but for your convenience we have a template for a development server you can use: [https://discord.new/zmHtscpYN9E3](https://discord.new/zmHtscpYN9E3). -Keep in mind that this is not a mirror of the Python server, but a reduced version for testing purposes. A lot of the channels in the Python server were merged. +Keep in mind that this is not an exact mirror of the Python server, but a reduced version for testing purposes. +The channels there are mostly the ones needed by the bot. --- ### Set Up a Bot Account @@ -61,398 +66,44 @@ If your bot fails to start with a `PrivilegedIntentsRequired` exception, this in --- ### Configure the Bot -You now have both the bot's code and a server to run it on. It's time you to connect the two by changing the bot's configurations. +You now have both the bot's code and a server to run it on. It's time for you to connect the two by setting the bot's configuration. -#### config.yml -Entering the directory of the cloned code, you will find a file named `config-default.yml`. -This file contains the various configurations we use to make the bot run on the Python Discord server, such as channel and role IDs, and the emojis it works with. -It also contains configurations such as how long it takes for a help channel to time out, and how many messages a user needs to voice-verify. +Both `.env` and `.env.server` files we talk about below are ignored by git, so they do not get accidentally commit to the repository. -To run the bot in your test server, you will need to override some of those configurations. -Create and open a new file in the directory called `config.yml`. Alternatively, copy the `config-default.yml` file and rename the copy to `config.yml`. -The bot will first look at the items in `config.yml`, and will fall back to `config-default.yml` only if necessary. Note that you don't have to specify all items in `config.yml`, just the ones you want to override such as channel IDs. +#### .env +This file will contain sensitive information such as your bot's token, do not share it with anybody! -See [here](../obtaining-discord-ids) for help with obtaining Discord IDs. +To start, create a `.env` file in the project root with the below content. -<div class="card"> - <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-fw fa-angle-down title is-5" aria-hidden="true"></i> - </span> - </button> - <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> - <code class="language-yaml"> -bot: - prefix: "!" - - redis: - host: "redis" - password: null - port: 6379 - use_fakeredis: true - - stats: - presence_update_timeout: 300 - statsd_host: "graphite.default.svc.cluster.local" - -urls: - # PyDis site vars - site: &DOMAIN "web:8000" - site_api: &API !JOIN [*DOMAIN, "/api"] - site_api_schema: "http://" - site_paste: &PASTE !JOIN ["paste.", "pythondiscord.com"] - site_schema: &SCHEMA "http://" - site_staff: &STAFF !JOIN [*DOMAIN, "/staff"] - - paste_service: !JOIN ["https://", *PASTE, "/{key}"] - site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] - - # Snekbox - snekbox_eval_api: "http://localhost:8060/eval" - -##### << Replace the following � characters with the channel IDs in your test server >> ##### -# This assumes the template was used: https://discord.new/zmHtscpYN9E3 -dev_guild: - id: &DEV_GUILD_ID � - - categories: - logs: &DEV_LOGS � - help_available: &DEV_HELP_AVAILABLE � - help_occupied: &DEV_HELP_OCCUPIED � - help_dormant: &DEV_HELP_DORMANT � - voice: &DEV_VOICE � - - channels: - # Staff - admins_mods: &DEV_ADMINS_MODS � - lounge_helpers_org: &DEV_LOUNGE_HELPERS_ORG � - defcon: &DEV_DEFCON � - incidents: &DEV_INCIDENTS � - incidents_archive: &DEV_INCIDENTS_ARCHIVE � - staff_announcements: &DEV_STAFF_ANNOUNCEMENTS � - dev_logs: &DEV_DEV_LOGS � - - # Logs - all_logs: &DEV_ALL_LOGS � - bb_logs: &DEV_BB_LOGS � - duck_pond: &DEV_DUCK_POND � - - # Available Help Channels - how_to_get_help: &DEV_HTGH � - - # Miscellaneous - bot_commands: &DEV_BOT_CMD � - general_meta_voice: &DEV_GMV � - dev_core_contrib: &DEV_DEV � - - # Voice - voice-verification: &DEV_VOICE_VER � - vc: &DEV_VC � - staff_voice: &DEV_STAFF_VOICE � - - # News - announcements: &DEV_ANNOUNCEMENTS � - py_news: &DEV_PY_NEWS � - - # Off-topic - off_topic_0: &DEV_OT_0 � - off_topic_1: &DEV_OT_1 � - off_topic_2: &DEV_OT_2 � - -guild: - ##### << Replace the following � characters with the role and webhook IDs in your test server >> ##### - roles: - announcements: � - contributors: � - help_cooldown: � - muted: &MUTED_ROLE � - partners: &PY_PARTNER_ROLE � - python_community: &PY_COMMUNITY_ROLE � - voice_verified: � - - # Staff - admins: &ADMINS_ROLE � - core_developers: � - devops: � - domain_leads: � - helpers: &HELPERS_ROLE � - moderators: &MODS_ROLE � - mod_team: &MOD_TEAM_ROLE � - owners: &OWNERS_ROLE � - code_jam_event_team: � - project_leads: � - - # Code Jam - team_leaders: � - - # Streaming - video: � - - webhooks: - big_brother: � - dev_log: � - duck_pond: � - incidents: � - incidents_archive: � - python_news: &PYNEWS_WEBHOOK � - talent_pool: � - - ##### << At this point your test bot should be able to mostly work with your test server >> ##### - # The following is the actual configs the bot uses, don't delete these. - id: *DEV_GUILD_ID - invite: "https://discord.gg/python" - - categories: - help_available: *DEV_HELP_AVAILABLE - help_dormant: *DEV_HELP_DORMANT - help_in_use: *DEV_HELP_OCCUPIED - logs: *DEV_LOGS - voice: *DEV_VOICE - - channels: - # Public announcement and news channels - announcements: *DEV_ANNOUNCEMENTS - change_log: *DEV_ANNOUNCEMENTS - mailing_lists: *DEV_ANNOUNCEMENTS - python_events: *DEV_ANNOUNCEMENTS - python_news: *DEV_PY_NEWS - - # Development - dev_contrib: *DEV_DEV - dev_core: *DEV_DEV - dev_log: *DEV_DEV_LOGS - - # Discussion - meta: *DEV_GMV - python_general: *DEV_GMV - - # Python Help: Available - cooldown: *DEV_HTGH - how_to_get_help: *DEV_HTGH - - # Topical - discord_py: *DEV_GMV - - # Logs - attachment_log: *DEV_ALL_LOGS - message_log: *DEV_ALL_LOGS - mod_log: *DEV_ALL_LOGS - user_log: *DEV_ALL_LOGS - voice_log: *DEV_ALL_LOGS - - # Off-topic - off_topic_0: *DEV_OT_0 - off_topic_1: *DEV_OT_1 - off_topic_2: *DEV_OT_2 - - # Special - bot_commands: *DEV_BOT_CMD - voice_gate: *DEV_VOICE_VER - code_jam_planning: *DEV_ADMINS_MODS - - # Staff - admins: *DEV_ADMINS_MODS - admin_spam: *DEV_ADMINS_MODS - defcon: *DEV_DEFCON - duck_pond: *DEV_DUCK_POND - helpers: *DEV_LOUNGE_HELPERS_ORG - incidents: *DEV_INCIDENTS - incidents_archive: *DEV_INCIDENTS_ARCHIVE - mods: *DEV_ADMINS_MODS - mod_alerts: *DEV_ADMINS_MODS - mod_meta: *DEV_ADMINS_MODS - mod_spam: *DEV_ADMINS_MODS - mod_tools: *DEV_ADMINS_MODS - organisation: *DEV_LOUNGE_HELPERS_ORG - staff_lounge: *DEV_LOUNGE_HELPERS_ORG - - # Staff announcement channels - admin_announcements: *DEV_STAFF_ANNOUNCEMENTS - mod_announcements: *DEV_STAFF_ANNOUNCEMENTS - staff_announcements: *DEV_STAFF_ANNOUNCEMENTS - - # Voice Channels - admins_voice: *DEV_STAFF_VOICE - code_help_voice_1: *DEV_VC - code_help_voice_2: *DEV_VC - general_voice: *DEV_VC - staff_voice: *DEV_STAFF_VOICE - - # Voice Chat - code_help_chat_1: *DEV_GMV - code_help_chat_2: *DEV_GMV - staff_voice_chat: *DEV_ADMINS_MODS - voice_chat: *DEV_GMV - - # Watch - big_brother_logs: *DEV_BB_LOGS - - moderation_categories: - - *DEV_LOGS - - moderation_channels: - - *DEV_ADMINS_MODS - - # Modlog cog ignores events which occur in these channels - modlog_blacklist: - - *DEV_ADMINS_MODS - - *DEV_ALL_LOGS - - *DEV_STAFF_VOICE - - reminder_whitelist: - - *DEV_BOT_CMD - - *DEV_DEV - - moderation_roles: - - *ADMINS_ROLE - - *MODS_ROLE - - *MOD_TEAM_ROLE - - *OWNERS_ROLE - - staff_roles: - - *ADMINS_ROLE - - *HELPERS_ROLE - - *MODS_ROLE - - *OWNERS_ROLE - -##### << The bot shouldn't fail without these, but commands adding specific emojis won't work. >> ##### -# You should at least set the trashcan. Set the incidents emojis if relevant. -style: - emojis: - badge_bug_hunter: "<:bug_hunter_lvl1:�>" - badge_bug_hunter_level_2: "<:bug_hunter_lvl2:�>" - badge_early_supporter: "<:early_supporter:�>" - badge_hypesquad: "<:hypesquad_events:�>" - badge_hypesquad_balance: "<:hypesquad_balance:�>" - badge_hypesquad_bravery: "<:hypesquad_bravery:�>" - badge_hypesquad_brilliance: "<:hypesquad_brilliance:�>" - badge_partner: "<:partner:�>" - badge_staff: "<:discord_staff:�>" - badge_verified_bot_developer: "<:verified_bot_dev:�>" - - defcon_shutdown: "<:defcondisabled:�>" - defcon_unshutdown: "<:defconenabled:�>" - defcon_update: "<:defconsettingsupdated:�>" - - failmail: "<:failmail:�>" - - #incident_actioned: "<:incident_actioned:�>" - incident_investigating: "<:incident_investigating:�>" - incident_unactioned: "<:incident_unactioned:�>" - - status_dnd: "<:status_dnd:�>" - status_idle: "<:status_idle:�>" - status_offline: "<:status_offline:�>" - status_online: "<:status_online:�>" - - trashcan: "<:trashcan:�>" - -##### << 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 - filter_everyone_ping: true - filter_invites: true - filter_zalgo: false - watch_regex: true - watch_rich_embeds: true - - # Notify user on filter? - # Notifications are not expected for "watchlist" type filters - notify_user_domains: false - notify_user_everyone_ping: true - notify_user_invites: true - notify_user_zalgo: false - - # Filter configuration - offensive_msg_delete_days: 7 # How many days before deleting an offensive message? - ping_everyone: true - - # Censor doesn't apply to these - channel_whitelist: - - *DEV_ADMINS_MODS - - *DEV_BB_LOGS - - *DEV_ALL_LOGS - - *DEV_LOUNGE_HELPERS_ORG - - role_whitelist: - - *ADMINS_ROLE - - *HELPERS_ROLE - - *MODS_ROLE - - *OWNERS_ROLE - - *PY_COMMUNITY_ROLE - - *PY_PARTNER_ROLE - -help_channels: - enable: true - - # Minimum interval before allowing a certain user to claim a new help channel - claim_minutes: 1 - - # Roles which are allowed to use the command which makes channels dormant - cmd_whitelist: - - *HELPERS_ROLE - - # Allowed duration of inactivity before making a channel dormant - idle_minutes: 1 - - # Allowed duration of inactivity when channel is empty (due to deleted messages) - # before message making a channel dormant - deleted_idle_minutes: 1 - - # Maximum number of channels to put in the available category - max_available: 2 - - # Maximum number of channels across all 3 categories - # Note Discord has a hard limit of 50 channels per category, so this shouldn't be > 50 - max_total_channels: 20 - - # Prefix for help channel names - name_prefix: 'help-' - - # Notify if more available channels are needed but there are no more dormant ones - notify: true - - # Channel in which to send notifications - notify_channel: *DEV_LOUNGE_HELPERS_ORG - - # Minimum interval between helper notifications - notify_minutes: 5 - - # Mention these roles in notifications - 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> -</div></div></div> -<br> +```text +BOT_TOKEN=YourDiscordBotTokenHere +GUILD_ID=YourDiscordTestServerIdHere +BOT_PREFIX=YourDesiredPrefixHere +``` +See [here](../creating-bot-account) for help with obtaining the bot token, and [here](../obtaining-discord-ids#guild-id) for help with obtaining the guild's ID. -If you don't wish to use the provided `config.yml` above, these are the main sections in `config-default.yml` that need overriding: +Other values will be added to your `.env` over time as you need to interact with other parts of the bot, but those are not needed for a basic setup. For a full list of support values see the ENV file option [appendix](#appendix-full-env-file-options) -* `guild.id` -* `guild.categories` -* `guild.channels` -* `guild.roles` -* `guild.webhooks` -* `style.emojis` +#### .env.server +All server related configuration values are saved in this file, which also needs to be at the root directory of the project. -Additionally: +We provide a script to automatically generate a server config. +**Note**: The script **only** works with servers created with the template mentioned above. -* At this stage, set `bot.redis.use_fakeredis` to `true`. If you're looking for instructions for working with Redis, see [Working with Redis](#optional-working-with-redis). -* Set `urls.site_api` to `!JOIN [*DOMAIN, "/api"]`. -* Set `urls.site_schema` and `urls.site_api_schema` to `"http://"`. +If you want to setup the bot from an existing guild read out [manual configuration guide](../bot-extended-configuration-options#manual-constants-configuration). This is far more complicated and time consuming. + +Running the below command will use the `BOT_TOKEN` and `GUILD_ID` from the `.env` file you created above to download all of the relevant IDs from the template guild into your `.env.server` + +**Note**: This script will overwrite the `.env.server` file. We suggest you put any configuration not generated by this script in to `.env` instead +```shell +$ poetry run task configure +``` + +Once the script has finished running, you'll notice the creation of a new file called `.env.server` at your project's root directory. +This file will contain the extracted IDs from your server which are necessary for your bot to run. + +**Congratulations**, you have finished the configuration and can now [run your bot](#run-it). -We understand this is tedious and are working on a better solution for setting up test servers. <div class="card"> <button type="button" class="card-header collapsible"> @@ -463,205 +114,86 @@ We understand this is tedious and are working on a better solution for setting u </button> <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. + While it's technically possible to edit the values in <code>constants.py</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. - In contrast, the <code>config.yml</code> file can remain in your local copy of the code, and will be ignored by commits via the project's <code>.gitignore</code>. + In contrast, the <code>.env.server</code> file can remain in your local copy of the code, and will be ignored by commits via the project's <code>.gitignore</code>. </div> </div> </div> <br> -#### .env -The second file you need to create is the one containing the environment variables, and needs to be named `.env`. -Inside, add the line `BOT_TOKEN=YourDiscordBotTokenHere`. See [here](../creating-bot-account) for help with obtaining the bot token. - -The `.env` file will be ignored by commits. - ---- ### Run it! #### With Docker You are now almost ready to run the Python bot. The simplest way to do so is with Docker. -<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 Docker</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"> - The requirements for Docker are: - <ul> - <li><a href="https://docs.docker.com/install">Docker CE</a></li> - <li>Docker Compose. If you're using macOS and Windows, this already comes bundled with the previous installation. Otherwise, you can download it either from the <a href="https://docs.docker.com/compose/install">website</a>, or by running <code>pip install docker-compose</code>.</li> - </ul> - <p class="notification is-warning">If you get any Docker related errors, reference the <a href="../docker#possible-issues">Possible Issue</a> section of the Docker page.</p> - </div> - </div> -</div> -<br> - -In your `config.yml` file: - -* Set `urls.site` to `"web:8000"`. -* If you wish to work with snekbox set `urls.snekbox_eval_api` to `"http://snekbox:8060/eval"`. - -Assuming you have Docker installed **and running**, enter the cloned repo in the command line and type `docker-compose up`. - -After pulling the images and building the containers, your bot will start. Enter your server and type `!help` (or whatever prefix you chose instead of `!`). - -Your bot is now running, but this method makes debugging with an IDE a fairly involved process. For additional running methods, continue reading the following sections. -#### With the Bot Running Locally -The advantage of this method is that you can run the bot's code in your preferred editor, with debugger and all, while keeping all the setup of the bot's various dependencies inside Docker. +With all of the above setup, you can run The projec with `docker compose up`. This will start the bot an all required services! Enter your server and type `!help` (or whatever prefix you chose instead of `!`) to see the bot in action! -* Append the following line to your `.env` file: `BOT_API_KEY=badbot13m0n8f570f942013fc818f234916ca531`. -* In your `config.yml` file, set `urls.site` to `"localhost:8000"`. If you wish to keep using `web:8000`, then [COMPOSE_PROJECT_NAME](../docker/#compose-project-names) has to be set. -* To work with snekbox, set `urls.snekbox_eval_api` to `"http://localhost:8060/eval"` +Some other useful docker commands are as follows: -You will need to start the services separately, but if you got the previous section with Docker working, that's pretty simple: - -* `docker-compose up web` to start the site container. This is required. -* `docker-compose up snekbox` to start the snekbox container. You only need this if you're planning on working on the snekbox cog. -* `docker-compose up redis` to start the Redis container. You only need this if you're not using fakeredis. For more info refer to [Working with Redis](#optional-working-with-redis). - -You can start several services together: `docker-compose up web snekbox redis`. - -##### Setting Up a Development Environment -The bot's code is Python code like any other. To run it locally, you will need the right version of Python with the necessary packages installed: - -1. Make sure you have [Python 3.9](https://www.python.org/downloads/) installed. It helps if it is your system's default Python version. -2. [Install Poetry](https://github.com/python-poetry/poetry#installation). -3. [Install the dependencies](../installing-project-dependencies). - -With at least the site running in Docker already (see the previous section on how to start services separately), you can now start the bot locally through the command line, or through your preferred IDE. -<div class="card"> - <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-fw fa-angle-down title is-5" aria-hidden="true"></i> - </span> - </button> - <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> - <li>Through the command line, inside the bot directory, with either <code>poetry run task start</code>, or directly <code>python -m bot</code>.</li> - <li>If using PyCharm, enter <code>Edit Configurations</code> and set everything according to this image: <img src="/static/images/content/contributing/pycharm_run_module.png"></li> - <li>If using Visual Studio Code, set the interpreter to the poetry environment you created. In <code>launch.json</code> create a new Python configuration, and set the name of the program to be run to <code>bot</code>. VSC will correctly run it as a module.</li> - </ul> - </div> - </div> -</div> -<br> +1. `docker compose pull` this pulls updates for all non-bot services, such as postgres, redis and our [site](../site) project! +1. `docker compose build` this rebuilds the bot's docker image, this is only needed if you need to make changes to the bot's dependencies, or the Dockerfile itself. -#### With More Things Running Locally -You can run additional services on the host, but this guide won't go over how to install and start them in this way. -If possible, prefer to start the services through Docker to replicate the production environment as much as possible. +Your bot is now running, all inside Docker. -The site, however, is a mandatory service for the bot. -Refer to the [previous section](#with-the-bot-running-locally) and the [site contributing guide](../site) to learn how to start it on the host, in which case you will need to change `urls.site` in `config.yml` to wherever the site is being hosted. +**Note**: If you want to read about how to make debugging with an IDE a easier, or for additional running methods, check out our [extended configuration guide](../bot-extended-configuration-options). --- -### Development Tips -Now that you have everything setup, it is finally time to make changes to the bot! -#### Working with Git +## Development Tips +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), now is a good time. -Contributions that do not adhere to the guidelines may be rejected. +### Working with Git -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/) -#### Running tests +### Running tests [This section](https://github.com/python-discord/bot/blob/main/tests/README.md#tools) of the README in the `tests` repository will explain how to run tests. The whole document explains how unittesting works, and how it fits in the context of our project. Make sure to run tests *before* pushing code. -Even if you run the bot through Docker, you might want to [setup a development environment](#setting-up-a-development-environment) in order to run the tests locally. +Even if you run the bot through Docker, you might want to [setup a development environment](../bot-extended-configuration-options#setting-up-a-development-environment) in order to run the tests locally. -#### Lint before you push +### Lint before you push As mentioned in the [contributing guidelines](../contributing-guidelines), you should make sure your code passes linting for each commit you make. For ease of development, you can install the pre-commit hook with `poetry run task precommit`, which will check your code every time you try to commit it. -For that purpose, even if you run the bot through Docker, you might want to [setup a development environment](#setting-up-a-development-environment), as otherwise the hook installation will fail. - -#### Reloading parts of the bot -If you make changes to an extension, you might not need to restart the entire bot for the changes to take effect. The command `!ext reload <extension_name>` re-imports the files associated with the extension. -Invoke `!ext list` for a full list of the available extensions. In this bot in particular, cogs are defined inside extensions. - -Note that if you changed code that is not associated with a particular extension, such as utilities, converters, and constants, you will need to restart the bot. +For that purpose, even if you run the bot through Docker, you might want to [setup a development environment](../bot-extended-configuration-options#setting-up-a-development-environment), as otherwise the hook installation will fail. -#### Adding new statistics - -Details on how to add new statistics can be found on the [statistic infrastructure page](https://blog.pythondiscord.com/statistics-infrastructure). -We are always open to more statistics so add as many as you can! - ---- - -### Optional: Working with Redis -In [Configure the Bot](#configyml) you were asked to set `bot.redis.use_fakeredis` to `true`. If you do not need to work on features that rely on Redis, this is enough. Fakeredis will give the illusion that features relying on Redis are saving information properly, but restarting the bot or the specific cog will wipe that information. - -If you are working on a feature that relies on Redis, you will need to enable Redis to make sure persistency is achieved for the feature across restarts. The first step towards that is going to `config.yml` and setting `bot.redis.use_fakeredis` to `false`. - -#### Starting Redis in Docker (Recommended) -If you're using the Docker image provided in the project's Docker Compose, open your `config.yml` file. If you're running the bot in Docker, set `bot.redis.host` to `redis`, and if you're running it on the host set it to `localhost`. Set `bot.redis.password` to `null`. - -#### Starting Redis Using Other Methods -You can run your own instance of Redis, but in that case you will need to correctly set `bot.redis.host` and `bot.redis.port`, and the `bot.redis.password` value in `config-default.yml` should not be overridden. Then, enter the `.env` file, and set `REDIS_PASSWORD` to whatever password you set. - ---- - -### Optional: Working with Metricity -[Metricity](https://github.com/python-discord/metricity) is our home-grown bot for collecting metrics on activity within the server, such as what users are present, and IDs of the messages they've sent. -Certain features in the Python bot rely on querying the Metricity database for information such as the number of messages a user has sent, most notably the voice verification system. - -If you wish to work on a feature that relies on Metricity, for your convenience we've made the process of using it relatively painless with Docker: Enter the `.env` file you've written for the Python bot, and append the line `USE_METRICITY=true`. -Note that if you don't need Metricity, there's no reason to have it enabled as it is just unnecessary overhead. - -To make the Metricity bot work with your test server, you will need to override its configurations similarly to the Python bot. -You can see the various configurations in [the Metricity repo](https://github.com/python-discord/metricity), but the bare minimum is the guild ID setting. -In your local version of the Python bot repo, create a file called `metricity-config.toml` and insert the following lines: -```yaml -[bot] -guild_id = replace_with_your_guild_id -``` -To properly replicate production behavior, set the `staff_role_id`, `staff_categories`, and `ignore_categories` fields as well. - -Now, `docker-compose up` will also start Metricity. +### Issues? +If you have any issues with setting up the bot, come discuss it with us on the [#dev-contrib](https://discord.gg/2h3qBv8Xaa) channel on our server. -If you want to run the bot locally, you can run `docker-compose up metricity` instead. +If you find any bugs in the bot or would like to request a feature, feel free to [open an issue](https://github.com/python-discord/bot/issues/new/choose) on the repository. --- -### Issues? -If you have any issues with setting up the bot, come discuss it with us on the [#dev-contrib](https://discord.gg/2h3qBv8Xaa) channel on our server. +# Next steps +Now that you have everything setup, it is finally time to make changes to the bot! If you have not yet read the [contributing guidelines](../contributing-guidelines.md), now is a good time. Contributions that do not adhere to the guidelines may be rejected. -If you find any bugs in the bot or would like to request a feature, feel free to open an issue on the repository. +Have fun! --- -### Appendix: Full ENV File Options +# Appendix: Full ENV File Options The following is a list of all available environment variables used by the bot: -| Variable | Required | Description | -| -------- | -------- | -------- | -| `BOT_TOKEN` | Always | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)). | -| `BOT_API_KEY` | When running bot without Docker | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below. | -| `BOT_SENTRY_DSN` | When connecting the bot to sentry | The DSN of the sentry monitor. | -| `BOT_TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. | -| `BOT_DEBUG` | In production | `true` or `false`, depending on whether to enable debug mode, affecting the behavior of certain features. `true` by default. -| `REDIS_PASSWORD` | When not using FakeRedis | The password to connect to the Redis database (see [Optional: Working with Redis](#optional-working-with-redis)). | -| `USE_METRICITY` | When using Metricity | `true` or `false`, depending on whether to enable metrics collection using Metricity (see [Optional: Working with Metricity](#optional-working-with-metricity)). `false` by default. | -| `GITHUB_API_KEY` | When you wish to interact with GitHub | The API key to interact with GitHub, for example to download files for the branding manager. -| `METABASE_USERNAME` | When you wish to interact with Metabase | The username for a Metabase admin account. -| `METABASE_PASSWORD` | When you wish to interact with Metabase | The password for a Metabase admin account. - -Have fun! +| Variable | Required | Description | +|----------------------|---------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `BOT_TOKEN` | Always | Your Discord bot account's token (see [Set Up a Bot Account](#set-up-a-bot-account)). | +| `GUILD_ID` | Always | Your Discord test server's id (see [Set Up a Test Server](#set-up-a-test-server)). | +| `BOT_PREFIX` | When you wish to use a prefix different than "!" | Your Discord bot command's prefix. | +| `API_KEYS_SITE_API` | When running bot without Docker | Used to authenticate with the site's API. When using Docker to run the bot, this is automatically set. By default, the site will always have the API key shown in the example below. | +| `BOT_SENTRY_DSN` | When connecting the bot to sentry | The DSN of the sentry monitor. | +| `BOT_TRACE_LOGGERS ` | When you wish to see specific or all trace logs | Comma separated list that specifies which loggers emit trace logs through the listed names. If the ! prefix is used, all of the loggers except the listed ones are set to the trace level. If * is used, the root logger is set to the trace level. | +| `DEBUG` | In production | `true` or `false`, depending on whether to enable debug mode, affecting the behavior of certain features. `true` by default. | +| `REDIS_PASSWORD` | When not using FakeRedis | The password to connect to the Redis database (see [Staring Redis with other methods](../bot-extended-configuration-options#starting-redis-using-other-methods)). | +| `USE_METRICITY` | When using Metricity | `true` or `false`, depending on whether to enable metrics collection using Metricity (see [Working with Metricity](../bot-extended-configuration-options#working-with-metricity)). `false` by default. | +| `API_KEYS_GITHUB` | When you wish to interact with GitHub | The API key to interact with GitHub, for example to download files for the branding manager. | +| `METABASE_USERNAME` | When you wish to interact with Metabase | The username for a Metabase admin account. | +| `METABASE_PASSWORD` | When you wish to interact with Metabase | The password for a Metabase admin account. | diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/commit-messages.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/commit-messages.md new file mode 100644 index 00000000..ba476b65 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/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.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines.md index de1777f2..d1e4250d 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/_info.yml b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/_info.yml deleted file mode 100644 index 80c8e772..00000000 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/contributing-guidelines/_info.yml +++ /dev/null @@ -1,2 +0,0 @@ -title: Contributing Guidelines -description: Guidelines to adhere to when contributing to our projects. 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/creating-bot-account.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/creating-bot-account.md index ee38baa3..51da3f34 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/creating-bot-account.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/creating-bot-account.md @@ -9,9 +9,9 @@ icon: fab fa-discord 4. Change your bot's `Public Bot` setting off so only you can invite it, save, and then get your **Bot Token** with the `Copy` button. > **Note:** **DO NOT** post your bot token anywhere public. If you do it can and will be compromised. 5. Save your **Bot Token** somewhere safe to use in the project settings later. -6. In the `General Information` tab, grab the **Client ID**. -7. Replace `<CLIENT_ID_HERE>` in the following URL and visit it in the browser to invite your bot to your new test server. +6. In the `General Information` tab, grab the **Application ID**. +7. Replace `<APPLICATION_ID_HERE>` in the following URL and visit it in the browser to invite your bot to your new test server. ```plaintext -https://discordapp.com/api/oauth2/authorize?client_id=<CLIENT_ID_HERE>&permissions=8&scope=bot +https://discordapp.com/api/oauth2/authorize?client_id=<APPLICATION_ID_HERE>&permissions=8&scope=bot ``` Optionally, you can generate your own invite url in the `OAuth` tab, after selecting `bot` as the scope. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/hosts-file.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/hosts-file.md index bba5722d..2da88b61 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/hosts-file.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/hosts-file.md @@ -8,7 +8,7 @@ toc: 3 # What's a hosts file? The hosts file maps a hostname/domain to an IP address, allowing you to visit a given domain on your browser and have it resolve by your system to the given IP address, even if it's pointed back to your own system or network. -When staging a local [Site](https://pythondiscord.com/pages/contributing/site/) project, you may want to add an entries to your hosts file so you can visit the site with the domain `http://pythondiscord.local`. This is purely for convenience, and you can use `localhost` or `127.0.0.1` instead if you prefer. +When staging a local [Site](https://pythondiscord.com/pages/guides/pydis-guides/contributing/site/) project, you may want to add an entries to your hosts file so you can visit the site with the domain `http://pythondiscord.local`. This is purely for convenience, and you can use `localhost` or `127.0.0.1` instead if you prefer. # What to add You would add the following entry to your hosts file. diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/linting.md new file mode 100644 index 00000000..b634f513 --- /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 `ruff` and `pre-commit` to ensure that the code style is consistent across the code base. + +`pre-commit` is a powerful tool that helps you automatically lint before you commit. +If the linter complains, the commit is aborted so that you can fix the linting errors before committing again. +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/sir-lancebot.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md index e3cd8f0c..7861c3d9 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot.md @@ -5,21 +5,24 @@ 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 -- [Python 3.9](https://www.python.org/downloads/) +Remember to ensure that you have read the [contributing guidelines](../contributing-guidelines) in full before you start contributing. + +### Requirements +- [Python 3.10.*](https://www.python.org/downloads/) - [Poetry](https://github.com/python-poetry/poetry#installation) - [Git](https://git-scm.com/downloads) - [Windows Installer](https://git-scm.com/download/win) - [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 @@ -65,9 +57,10 @@ You will need your own test server and bot account on Discord to test your chang 3. Create the following text channels: * `#announcements` * `#dev-log` - * `#sir-lancebot-commands` + * `#sir-lancebot-playground` 4. Create the following roles: - * `@Admin` + * `@Admins` + * `@Helpers` 5. Note down the IDs for your server, as well as any channels and roles created. * [**Learn how to obtain the ID of a server, channel or role here.**](../setting-test-server-and-bot-account#obtain-the-ids) @@ -80,21 +73,21 @@ You will have to setup environment variables: The following variables are needed for running Sir Lancebot: -| Environment Variable | Description | -| -------- | -------- | -| `BOT_TOKEN` | Bot Token from the [Discord developer portal](https://discord.com/developers/applications) | -| `BOT_GUILD` | ID of the Discord Server | -| `BOT_ADMIN_ROLE_ID` | ID of the role `@Admins` | -| `ROLE_HELPERS` | ID of the role `@Helpers` | -| `CHANNEL_ANNOUNCEMENTS` | ID of the `#announcements` channel | -| `CHANNEL_DEVLOG` | ID of the `#dev-log` channel | -| `CHANNEL_COMMUNITY_BOT_COMMANDS` | ID of the `#sir-lancebot-commands` channel | +| Environment Variable | Description | +|------------------------------------|--------------------------------------------------------------------------------------------| +| `CLIENT_TOKEN` | Bot Token from the [Discord developer portal](https://discord.com/developers/applications) | +| `CLIENT_GUILD` | ID of the Discord Server | +| `ROLES_ADMIN` | ID of the role `@Admins` | +| `ROLES_HELPERS` | ID of the role `@Helpers` | +| `CHANNELS_ANNOUNCEMENTS` | ID of the `#announcements` channel | +| `CHANNELS_DEVLOG` | ID of the `#dev-log` channel | +| `CHANNELS_SIR_LANCEBOT_PLAYGROUND` | ID of the `#sir-lancebot-playground` channel | -[**Full environment variable reference for this project.**](./env-var-reference) +[**Full environment variable reference for this project.**](../sir-lancebot/env-var-reference) --- -While not required, we advise you set `USE_FAKEREDIS` to `true` in development to avoid the need of setting up a Redis server. +While not required, we advise you set `REDIS_USE_FAKEREDIS` to `true` in development to avoid the need of setting up a Redis server. It does mean you may lose persistent data on restart but this is non-critical. Otherwise, please see the below linked guide for Redis related variables. {: .notification .is-warning } @@ -104,11 +97,11 @@ Otherwise, please see the below linked guide for Redis related variables. The sections below describe the two ways you can run this project. We recommend Docker as it requires less setup. ## Run with Docker -Make sure to have Docker running, then use the Docker command `docker-compose up` in the project root. +Make sure to have Docker running, then use the Docker command `docker compose up` in the project root. The first time you run this command, it may take a few minutes while Docker downloads and installs Sir Lancebot's dependencies. ```shell -$ docker-compose up +$ docker compose up ``` If you get any Docker related errors, reference the [Possible Issues](../docker#possible-issues) section of the Docker page. @@ -120,14 +113,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/sir-lancebot/env-var-reference.md b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot/env-var-reference.md index 51587aac..342da12b 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot/env-var-reference.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/sir-lancebot/env-var-reference.md @@ -6,70 +6,58 @@ toc: 2 ## General Variables The following variables are needed for running Sir Lancebot: -| Environment Variable | Description | -| -------- | -------- | -| `BOT_TOKEN` | Bot Token from the [Discord developer portal](https://discord.com/developers/applications) | -| `BOT_GUILD` | ID of the Discord Server | -| `BOT_ADMIN_ROLE_ID` | ID of the role @Admins | -| `ROLE_HELPERS` | ID of the role @Helpers | -| `CHANNEL_ANNOUNCEMENTS` | ID of the #announcements channel | -| `CHANNEL_DEVLOG` | ID of the #dev-log channel | -| `CHANNEL_COMMUNITY_BOT_COMMANDS` | ID of the #sir-lancebot-commands channel | -| `CHANNEL_REDDIT` | ID of the #reddit channel | +| Environment Variable | Description | +|------------------------------------|--------------------------------------------------------------------------------------------| +| `CLIENT_TOKEN` | Bot Token from the [Discord developer portal](https://discord.com/developers/applications) | +| `CLIENT_GUILD` | ID of the Discord Server | +| `ROLES_ADMINS` | ID of the role `@Admins` | +| `ROLES_HELPERS` | ID of the role `@Helpers` | +| `CHANNELS_ANNOUNCEMENTS` | ID of the `#announcements` channel | +| `CHANNELS_DEVLOG` | ID of the `#dev-log` channel | +| `CHANNELS_SIR_LANCEBOT_PLAYGROUND` | ID of the `#sir-lancebot-commands` channel | +| `CHANNELS_REDDIT` | ID of the `#reddit` channel | --- ## Debug Variables Additionally, you may find the following environment variables useful during development: -| Environment Variable | Description | -| -------- | -------- | -| `BOT_DEBUG` | Debug mode of the bot | False | -| `PREFIX` | The bot's invocation prefix | `.` | -| `CYCLE_FREQUENCY` | Amount of days between cycling server icon | 3 | -| `MONTH_OVERRIDE` | Integer in range `[0, 12]`, overrides current month w.r.t. seasonal decorators | -| `REDIS_HOST` | The address to connect to for the Redis database. | -| `REDIS_PORT` | | -| `REDIS_PASSWORD` | | -| `USE_FAKEREDIS` | If the FakeRedis module should be used. Set this to true if you don't have a Redis database setup. | -| `BOT_SENTRY_DSN` | The DSN of the sentry monitor. | -| `TRASHCAN_EMOJI` | The full emoji to use for the trashcan. Format should be like the output of `\:emoji:`. | - +| Environment Variable | Description | +|----------------------------|------------------------------------------------------------------------------------------------------------| +| `CLIENT_DEBUG` | Debug mode of the bot | +| `CLIENT_PREFIX` | The bot's invocation prefix | +| `CLIENT_MONTH_OVERRIDE` | Integer in range `[0, 12]`, overrides current month w.r.t. seasonal decorators | +| `REDIS_HOST` | The address to connect to for the Redis database. | +| `REDIS_PORT` | The port on which the Redis database is exposed. | +| `REDIS_PASSWORD` | The password to connect to the Redis database. | +| `REDIS_USE_FAKEREDIS` | If the FakeRedis module should be used. Set this to true if you don't have a Redis database setup. | +| `BOT_SENTRY_DSN` | The DSN of the sentry monitor. | +| `TRASHCAN_EMOJI` | The full emoji to use for the trashcan. Format should be like the output of sending `\:emoji:` on discord. | --- ## Tokens/APIs If you will be working with an external service, you might have to set one of these tokens: -| Token | Description | -| -------- | -------- | -| `GITHUB_TOKEN` | Personal access token for GitHub, raises rate limits from 60 to 5000 requests per hour. | -| `GIPHY_TOKEN` | Required for API access. [Docs](https://developers.giphy.com/docs/api) | -| `OMDB_API_KEY` | Required for API access. [Docs](https://www.omdbapi.com/) | -| `REDDIT_CLIENT_ID` | OAuth2 client ID for authenticating with the [reddit API](https://github.com/reddit-archive/reddit/wiki/OAuth2). | -| `REDDIT_SECRET` | OAuth2 secret for authenticating with the reddit API. *Leave empty if you're not using the reddit API.* | -| `REDDIT_WEBHOOK` | Webhook ID for Reddit channel | -| `YOUTUBE_API_KEY` | An OAuth Key or Token are required for API access. [Docs](https://developers.google.com/youtube/v3/docs#calling-the-api) | -| `TMDB_API_KEY` | Required for API access. [Docs](https://developers.themoviedb.org/3/getting-started/introduction) | -| `NASA_API_KEY` | Required for API access. [Docs](https://api.nasa.gov/) | -| `WOLFRAM_API_KEY` | | -| `UNSPLASH_KEY` | Required for API access. Use the `access_token` given by Unsplash. [Docs](https://unsplash.com/documentation) | -| `IGDB_CLIENT_ID` | OAuth2 client ID for authenticating with the [IGDB API](https://api-docs.igdb.com/) | -| `IGDB_CLIENT_SECRET` | OAuth2 secret for authenticating with the IGDB API. *Leave empty if you're not using the IGDB API.* | +| Token | Description | +|-----------------------------|--------------------------------------------------------------------------------------------------------------------------| +| `TOKENS_GITHUB` | Personal access token for GitHub, raises rate limits from 60 to 5000 requests per hour. | +| `TOKENS_GIPHY` | Required for API access. [Docs](https://developers.giphy.com/docs/api) | +| `REDDIT_CLIENT_ID` | OAuth2 client ID for authenticating with the [reddit API](https://github.com/reddit-archive/reddit/wiki/OAuth2). | +| `REDDIT_SECRET` | OAuth2 secret for authenticating with the reddit API. *Leave empty if you're not using the reddit API.* | +| `REDDIT_WEBHOOK` | Webhook ID for Reddit channel | +| `TOKENS_YOUTUBE` | An OAuth Key or Token are required for API access. [Docs](https://developers.google.com/youtube/v3/docs#calling-the-api) | +| `TOKENS_TMDB` | Required for API access. [Docs](https://developers.themoviedb.org/3/getting-started/introduction) | +| `TOKENS_NASA` | Required for API access. [Docs](https://api.nasa.gov/) | +| `WOLFRAM_KEY` | Required for API access. [Docs](https://products.wolframalpha.com/simple-api/documentation) | +| `TOKENS_UNSPLASH` | Required for API access. Use the `access_token` given by Unsplash. [Docs](https://unsplash.com/documentation) | +| `TOKENS_IGDB_CLIENT_ID` | OAuth2 client ID for authenticating with the [IGDB API](https://api-docs.igdb.com/) | +| `TOKENS_IGDB_CLIENT_SECRET` | OAuth2 secret for authenticating with the IGDB API. *Leave empty if you're not using the IGDB API.* | --- ## Seasonal Cogs These variables might come in handy while working on certain cogs: -| Cog | Environment Variable | Description | -| -------- | -------- | -------- | -| Advent of Code | `AOC_LEADERBOARDS` | List of leaderboards separated by `::`. Each entry should have an `id,session cookie,join code` separated by commas in that order. | -| Advent of Code | `AOC_STAFF_LEADERBOARD_ID` | Integer ID of the staff leaderboard. | -| Advent of Code | `AOC_ROLE_ID` | ID of the advent of code role. -| Advent of Code | `AOC_IGNORED_DAYS` | Comma separated list of days to ignore while calculating score. | -| Advent of Code | `AOC_YEAR` | Debug variable to change the year used for AoC. | -| Advent of Code | `AOC_CHANNEL_ID` | The ID of the #advent-of-code channel | -| Advent of Code | `AOC_COMMANDS_CHANNEL_ID` | The ID of the #advent-of-code-commands channel | -| Advent of Code | `AOC_FALLBACK_SESSION` | | -| Advent of Code | `AOC_SESSION_COOKIE` | | -| Valentines | `LOVEFEST_ROLE_ID` | | -| Wolfram | `WOLFRAM_USER_LIMIT_DAY` | | -| Wolfram | `WOLFRAM_GUILD_LIMIT_DAY` | | +| Cog | Environment Variable | Description | +|------------|---------------------------|------------------------------------------------------------------| +| Valentines | `ROLES_LOVEFEST` | ID of the role `@Lovefest` | +| Wolfram | `WOLFRAM_USER_LIMIT_DAY` | The amount of requests a user can make per day | +| Wolfram | `WOLFRAM_GUILD_LIMIT_DAY` | The amount of requests that can come from the same guild per day | 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..1927f449 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/contributing/site.md @@ -5,9 +5,11 @@ 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). -- [Python 3.9](https://www.python.org/downloads/) +### Requirements + +- [Python 3.11](https://www.python.org/downloads/) - [Poetry](https://python-poetry.org/docs/#installation) - `pip install poetry` - [Git](https://git-scm.com/downloads) @@ -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 @@ -64,7 +53,7 @@ CREATE DATABASE pysite WITH OWNER pysite; CREATE DATABASE metricity WITH OWNER pysite; ``` -Finally, enter `/q` to exit psql. +Finally, enter `\q` to exit psql. ### 2. Environment variables @@ -84,7 +73,7 @@ detailed information about these settings. #### Notes regarding `DATABASE_URL` - If the database is hosted locally i.e. on the same machine as the webserver, then use `localhost` for the host. Windows and macOS users may need to use the [Docker host IP](https://stackoverflow.com/questions/22944631/how-to-get-the-ip-address-of-the-docker-host-from-inside-a-docker-container) instead. -- If the database is running in Docker, use port `7777`. Otherwise, use `5432` as that is the default port used by PostegreSQL. +- If the database is running in Docker, use port `7777`. Otherwise, use `5432` as that is the default port used by PostgreSQL. - If you configured PostgreSQL in a different manner or you are not hosting it locally, then you will need to determine the correct host and port yourself. The user, password, and database name should all still be `pysite` unless you deviated from the setup instructions in the previous section. @@ -103,7 +92,7 @@ docker-compose up The `-d` option can be appended to the command to run in detached mode. This runs the containers in the background so the current terminal session is available for use with other things. -If you get any Docker related errors, reference the [Possible Issues](https://pythondiscord.com/pages/contributing/docker/#possible-issues") section of the Docker page. +If you get any Docker related errors, reference the [Possible Issues](https://pythondiscord.com/pages/guides/pydis-guides/contributing/docker/#possible-issues") section of the Docker page. {: .notification .is-warning } ## Run on the host @@ -140,6 +129,14 @@ Unless you are editing the Dockerfile or docker-compose.yml, you shouldn't need [**Click here to see the basic Git workflow when contributing to one of our projects.**](../working-with-git/) --- +# Deploy previews + +When you open a pull request, the `netlify` bot will build and publish a static +preview of your changes, which is very valuable if you made any changes to the +content or styling of the website. An example deploy preview can be found on +[pull request #773](https://github.com/python-discord/site/pull/773#issuecomment-1257224147). + +--- # Django admin site Django provides an interface for administration with which you can view and edit the models among other things. @@ -178,3 +175,17 @@ 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), or the [guide on contributing a page](../../how-to-contribute-a-page) is for you. + +The site repository also contains `README.md` files in all major directories of +interest, which explain where which functionality of the site is located. For +example, see the [API app's +README](https://github.com/python-discord/site/tree/main/pydis_site/apps/api). + +Have fun! 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..b26c467c 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,14 @@ 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. 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..bef2df9b 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,73 +6,100 @@ 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 **help forum posts**. # 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`. + +# Help Forum Posts + +Help forum posts 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 posts are a great choice for generic Python help, but can be used for domain-specific Python help as well. -# General Help Channels +## How to Create A New Post -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. +There are 4 easy needed steps to make this happen -## How To Claim a Channel +1. Navigate to the **Python Help System** category.<br> + +2. Open the **python-help** forum channel. +3. Click on the **New Post** button in the top-right corner.<br> + +4. Choose a brief title that best describes your issue, along with a message explaining it more in details, and **post** it. +Note that you can also choose one or more tags which can help attract experts of that tag easily.<br> + -There are always 3 available help channels waiting to be claimed in the **Python Help: Available** category. +Be sure to [ask questions with enough information](../asking-good-questions) in order to give yourself the best chances of getting help! - +At this point you will have the **Help Cooldown** role which will remain on your profile until you close your newly created post. This ensures that users can only have one post at any given time, giving everyone a chance to have their question seen. -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. +# Frequently Asked Questions -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. +### I created a new help post, what happens now? +Once you click on `Post`, these events take place:<br> +1. A new channel will be created for you, and you'll have an `OP` next to you username, which tells people you're the `Original Poster`, or in other words, the owner of the help topic in that channel.<br> +2. Your original question/message will always be the first one in that channel.<br> +3. Our Python bot will send a message reminding you of what you should include in your question/message in case you could have missed anything.<br> +4. People will be able to jump on that channel, and you can have a discussion with anyone who's volunteering to help you by asking as many followup questions as you want.<br> - -*This message is always posted when a channel becomes available for use.* +#### Example +Suppose we're trying to find the minimum value in a list of integers. +Once we've chosen our title and message content, we are ready to make a new post.<br><br> +<br><br> +Note how we've checked the **Algos & data structs** tag here, whose circumference is highlighted in blue, since this is a question about an algorithm to find the minimum.<br> +This will greatly help others pinpoint where they can help you best based on a combination of your title and tag from a first glance.<br><br> +Once you click on post, a new channel is created, and you can see the original message on top along with the `OP` tag next to the poster's avatar.<br> +You will also see the message that our Python bot sends instantly right after yours.<br><br> + -## Q: For how long is the channel mine? +### How long does my help post 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 post 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 post once it goes dormant.* -## Q: I don't need my help channel anymore, my question was answered. What do I do? +### No one answered my question. How come? -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. +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 open another post a little later, or try an appropriate topical channel. -## Q: Are only Helpers supposed to answer questions? +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. -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. +### My question was answered. What do I do? - +Go ahead and use one of the `!close` or `!solved` commands if you've satisfactorily solved your problem. You will only be able to run this command in your own post, and no one (outside of staff) will be able to close your post for you. -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). +Closing your post once you are finished leads to less occupied ones, which means more attention can be given to other users that still need help. -## Q: I lost my help channel! +### Can only Helpers answer help questions? -No need to panic. -Your channel was probably just marked as dormant. -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. +Definitely not! We encourage all members of the community to participate in giving help. If you'd like to help answer some questions, you can either browse all posts in the **python-help** forum channel or head over to the **Topical Chat/Help** category. + +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. + +Tip: run the `!helpdm on` command in the `#bot-commands` channel to get notified via DM with jumplinks to help posts you're participating in. - -*The dormant help channels can be found at the bottom of the channel list.* +### Can I save my help session for future reference? + +Yes! Because the help posts are only closed without being deleted, this means you can always refer to a previous help session if you found one particularly helpful. + +Tip: reply to a message and run the `.bm` command to get bookmarks sent to you via DM for future reference. + +### I lost my help post! + +No need to panic. Your post was probably just closed due to inactivity. +All the dormant help posts are still available at the bottom of the **python-help** forum channel and also through search in the **Python Help System** category. +If you're not sure what the title of your help post 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/pydis-guides/helping-others.md b/pydis_site/apps/content/resources/guides/pydis-guides/helping-others.md index a7f1ce1d..9f0d947f 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/helping-others.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/helping-others.md @@ -9,7 +9,7 @@ relevant_links: toc: 2 --- -Python Discord has a lot of people asking questions, be it in the help channels, topical channels, or any other part of the server. +Python Discord has a lot of people asking questions, be it in the help forum, topical channels, or any other part of the server. Therefore, you might sometimes want to give people the answers you have in mind. But you might not be sure how best to approach the issue, or maybe you'd like to see how others handle it. This article aims to present a few of the general principles which guide the staff on a day-to-day basis on the server. @@ -64,7 +64,7 @@ At other times, it might not be as obvious, and it might be a good idea to kindl The path is often more important than the answer. Your goal should primarily be to allow the helpee to apply, at least to a degree, the concepts you introduce in your answer. Otherwise, they might keep struggling with the same problem over and over again. -That means that simply showing your answer might close the help channel for the moment, but won't be very helpful in the long-term. +That means that simply showing your answer might close the help post for the moment, but won't be very helpful in the long-term. A common approach is to walk the helpee through to an answer: diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md b/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md index 716250b1..65a402fd 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md @@ -3,8 +3,8 @@ title: How to Contribute a Page description: Learn how to write and publish a page to this website. icon: fas fa-info relevant_links: - Contributing to Site: https://pythondiscord.com/pages/contributing/site/ - Using Git: https://pythondiscord.com/pages/contributing/working-with-git/ + Contributing to Site: https://pythondiscord.com/pages/guides/pydis-guides/contributing/site/ + Using Git: https://pythondiscord.com/pages/guides/pydis-guides/contributing/working-with-git/ toc: 4 --- @@ -14,8 +14,8 @@ If you are interested in writing or modifying pages seen here on the site, follo For further assistance and help with contributing pages, send a message to the `#dev-contrib` channel in the Discord server! ## Prerequisites -Before working on a new page, you have to [setup the site project locally](https://pythondiscord.com/pages/contributing/site/). -It is also a good idea to familiarize yourself with the [git workflow](https://pythondiscord.com/pages/contributing/working-with-git/), as it is part of the contribution workflow. +Before working on a new page, you have to [setup the site project locally](https://pythondiscord.com/pages/guides/pydis-guides/contributing/site/). +It is also a good idea to familiarize yourself with the [git workflow](https://pythondiscord.com/pages/guides/pydis-guides/contributing/working-with-git/), as it is part of the contribution workflow. Additionally, please submit your proposed page or modification to a page as an [issue in the site repository](https://github.com/python-discord/site/issues), or discuss it in the `#dev-contrib` channel in the server. As website changes require staff approval, discussing the page content beforehand helps with accelerating the contribution process, and avoids wasted work in the event the proposed page is not accepted. @@ -68,8 +68,8 @@ title: How to Contribute a Page description: Learn how to write and publish a page to this website. icon: fas fa-info relevant_links: - Contributing to Site: https://pythondiscord.com/pages/contributing/site/ - Using Git: https://pythondiscord.com/pages/contributing/working-with-git/ + Contributing to Site: https://pythondiscord.com/pages/guides/pydis-guides/contributing/site/ + Using Git: https://pythondiscord.com/pages/guides/pydis-guides/contributing/working-with-git/ --- Pages, which include guides, articles, and other static content,... @@ -169,7 +169,7 @@ path = os.path.join("foo", "bar") ### HTML Attributes To add HTML attributes to certain lines/paragraphs, [see this page](https://python-markdown.github.io/extensions/attr_list/#the-list) for the format and where to put it. -This can be useful for setting the image size when adding an image using markdown (see the [Image Captions](#image-captions) section for an example), or for adding bulma styles to certain elements (like the warning notification [here](/pages/guides/pydis-guides/contributing/sir-lancebot#setup-instructions)).<br> +This can be useful for setting the image size when adding an image using markdown (see the [Image Captions](#image-captions) section for an example), or for adding bulma styles to certain elements (like the warning notification [here](/pages/guides/pydis-guides/contributing/sir-lancebot#run-with-docker)).<br> **This should be used sparingly, as it reduces readability and simplicity of the article.** --- @@ -215,3 +215,44 @@ To use a custom label in the table of contents for a heading, set the `data-toc- ```markdown # Header 1 {: data-toc-label="Header One" } ``` + +## Tips + +### Nested/Unhighlighted Code Blocks +To nest code blocks, increase the number of ticks in the outer block by 1. To remove highlighting from code blocks (ie. no dark background), you can use the `nohighlight` language. +`````nohighlight +````nohighlight +```python +print("Some inner code block text.") +``` +```` +````` + +### Images in Lists +To properly indent images in lists, keep the image on the line directly after the previous line and add `<br>` to the end of the text, like this: + +```markdown +1. List item text one.<br> + + +2. List item text two.<br> + +``` + +### Keeping Text In The Same Paragraph +You can also use `<br>` to break lines while keeping them in the same paragraph (avoiding the vertical spacing added between paragraphs). + +```nohighlight +##### Same line, same paragraph +Line A +Line B + +##### Different line, different paragraph +Line A + +Line B + +##### Different line, same paragraph +Line A<br> +Line B +``` diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/off-topic-etiquette.md b/pydis_site/apps/content/resources/guides/pydis-guides/off-topic-etiquette.md index f8031834..5e785cd9 100644 --- a/pydis_site/apps/content/resources/guides/pydis-guides/off-topic-etiquette.md +++ b/pydis_site/apps/content/resources/guides/pydis-guides/off-topic-etiquette.md @@ -5,7 +5,7 @@ icon: fab fa-discord --- ## Why do we need off-topic etiquette? -Everyone wants to have good conversations in our off-topic channels, but with tens of thousands of members, this might mean different things to different people. +Everyone wants to have good conversations in our off-topic channels, but with hundreds of thousands of members, this might mean different things to different people. To facilitate the best experience for everyone, here are some guidelines on conversation etiquette. ## Three things you shouldn't do diff --git a/pydis_site/apps/content/resources/guides/python-guides/app-commands.md b/pydis_site/apps/content/resources/guides/python-guides/app-commands.md new file mode 100644 index 00000000..713cd650 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/app-commands.md @@ -0,0 +1,418 @@ +--- +title: Discord.py 2.0 changes +description: Changes and new features in version 2.0 of discord.py +--- + +Upon the return of the most popular discord API wrapper library for Python, `discord.py`, while catching on to the latest features of the discord API, there have been numerous changes with additions of features to the library. Additions to the library include support for Buttons, Select Menus, Forms (AKA Modals), Slash Commands (AKA Application Commands) and a bunch more handy features! All the changes can be found [here](https://discordpy.readthedocs.io/en/latest/migrating.html). Original discord.py Gist regarding resumption can be found [here](https://gist.github.com/Rapptz/c4324f17a80c94776832430007ad40e6). + + +# Install the latest version of discord.py + +Before you can make use of any of the new 2.0 features, you need to install the latest version of discord.py. Make sure that the version is 2.0 or above! +Also, make sure to uninstall any third party libraries intended to add slash-command support to pre-2.0 discord.py, as they are no longer necessary and will likely cause issues. + +The latest and most up-to-date stable discord.py version can be installed using `pip install -U discord.py`. + +**Before migrating to discord.py 2.0, make sure you read the migration guide [here](https://discordpy.readthedocs.io/en/latest/migrating.html) as there are lots of breaking changes.**. +{: .notification .is-warning } + +# What are Slash Commands? + +Slash Commands are an exciting new way to build and interact with bots on Discord. As soon as you type "/", you can easily see all the commands a bot has. It also comes with autocomplete, validation and error handling, which will all help users of your bot get the command right the first time. + +# Basic structure for discord.py Slash Commands! + +### Note that Slash Commands in discord.py are also referred to as **Application Commmands** and **App Commands** and every *interaction* is a *webhook*. +Slash commands in discord.py are held by a container, [CommandTree](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=commandtree#discord.app_commands.CommandTree). A command tree is required to create Slash Commands in discord.py. This command tree provides a `command` method which decorates an asynchronous function indicating to discord.py that the decorated function is intended to be a slash command. This asynchronous function expects a default argument which acts as the interaction which took place that invoked the slash command. This default argument is an instance of the **Interaction** class from discord.py. Further up, the command logic takes over the behaviour of the slash command. + +# Fundamentals for this Gist! + +One new feature added in discord.py v2 is `setup_hook`. `setup_hook` is a special asynchronous method of the Client and Bot classes which can be overwritten to perform numerous tasks. This method is safe to use as it is always triggered before any events are dispatched, i.e. this method is triggered before the *IDENTIFY* payload is sent to the discord gateway. +Note that methods of the Bot class such as `change_presence` will not work in setup_hook as the current application does not have an active connection to the gateway at this point. +A full list of commands you can't use in setup_hook can be found [here](https://discord.com/developers/docs/topics/gateway-events#send-events). + +__**THE FOLLOWING ARE EXAMPLES OF HOW A `SETUP_HOOK` FUNCTION CAN BE DEFINED**__ + +Note that the default intents are defined [here](https://discordpy.readthedocs.io/en/stable/api.html?highlight=discord%20intents%20default#discord.Intents.default) to have all intents enabled except presences, members, and message_content. + +```python +import discord + +# You can create the setup_hook directly in the class definition + +class SlashClient(discord.Client): + def __init__(self) -> None: + super().__init__(intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + ... + +# Or add it to the client after creating it + +client = discord.Client(intents=discord.Intents.default()) +async def my_setup_hook() -> None: + ... + +client.setup_hook = my_setup_hook +``` + +# Basic Slash Command application using discord.py. + +#### The `CommandTree` class resides within the `app_commands` of the discord.py package. + +## Slash Command Application with a Client + +```python +import discord + +class SlashClient(discord.Client): + def __init__(self) -> None: + super().__init__(intents=discord.Intents.default()) + self.tree = discord.app_commands.CommandTree(self) + + async def setup_hook(self) -> None: + self.tree.copy_global_to(guild=discord.Object(id=12345678900987654)) + await self.tree.sync() + +client = SlashClient() + [email protected](name="ping", description="...") +async def _ping(interaction: discord.Interaction) -> None: + await interaction.response.send_message("pong") + +client.run("token") +``` + + +__**EXPLANATION**__ + +- `import discord` imports the **discord.py** package. +- `class SlashClient(discord.Client)` is a class subclassing **Client**. Though there is no particular reason except readability to subclass the **Client** class, using the `Client.setup_hook = my_func` is equally valid. +- Next up `super().__init__(...)` runs the `__init__` function of the **Client** class, this is equivalent to `discord.Client(...)`. Then, `self.tree = discord.app_commands.CommandTree(self)` creates a CommandTree which acts as the container for slash commands. +- Then in the `setup_hook`, `self.tree.copy_global_to(...)` adds the slash command to the guild of which the ID is provided as a `discord.Object` object. **Essential to creation of commands** Further up, `self.tree.sync()` updates the API with any changes to the Slash Commands. +- Finishing up with the **Client** subclass, we create an instance of the subclassed Client class which here has been named as `SlashClient` with `client = SlashClient()`. +- Then using the `command` method of the `CommandTree` we decorate a function with it as `client.tree` is an instance of `CommandTree` for the current application. The command function takes a default argument as said, which acts as the interaction that took place. Catching up is `await interaction.response.send_message("pong")` which sends back a message to the slash command invoker. +- And the classic old `client.run("token")` is used to connect the client to the discord gateway. +- Note that the `send_message` is a method of the `InteractionResponse` class and `interaction.response` in this case is an instance of the `InteractionResponse` object. The `send_message` method will not function if the response is not sent within 3 seconds of command invocation. We will discuss how to handle this issue later following the Gist. + +## Slash Command application with the Bot class + +```python +import discord + +class SlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix=".", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + self.tree.copy_global_to(guild=discord.Object(id=12345678900987654)) + await self.tree.sync() + +bot = SlashBot() + [email protected](name="ping", description="...") +async def _ping(interaction: discord.Interaction) -> None: + await interaction.response.send_message("pong") + +bot.run("token") +``` + +The above example shows a basic Slash Commands within discord.py using the Bot class. + +__**EXPLANATION**__ + +Most of the explanation is the same as the prior example that featured `SlashClient` which was a subclass of **discord.Client**. Though some minor changes are discussed below. + +- The `SlashBot` class now subclasses `discord.ext.commands.Bot` following the passing in of the required arguments to its `__init__` method. +- `discord.ext.commands.Bot` already consists of an instance of the `CommandTree` class which can be accessed using the `tree` property. + +# Slash Commands within a Cog! + +A cog is a collection of commands, listeners, and optional state to help group commands together. More information on them can be found on the [Cogs](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs) page. + +## An Example to using cogs with discord.py for Slash Commands! + +```python +import discord +from discord.ext import commands +from discord import app_commands + +class MySlashCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @app_commands.command(name="ping", description="...") + async def _ping(self, interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +class MySlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix="!", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + await self.add_cog(MySlashCog(self)) + await self.tree.copy_global_to(discord.Object(id=123456789098765432)) + await self.tree.sync() + +bot = MySlashBot() + +bot.run("token") +``` + +__**EXPLANATION**__ + +- Firstly, `import discord` imports the **discord.py** package. `from discord import app_commands` imports the `app_commands` module from the **discord.py** root module. `from discord.ext import commands` imports the commands extension. +- Further up, `class MySlashCog(commands.Cog)` is a class subclassing the `Cog` class. You can read more about this [here](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html#ext-commands-cogs). +- `def __init__(self, bot: commands.Bot): self.bot = bot` is the constructor method of the class that is always run when the class is instantiated and that is why we pass in a **Bot** object whenever we create an instance of the cog class. +- Following up is the `@app_commands.command(name="ping", description="...")` decorator. This decorator basically functions the same as a `bot.tree.command` but since the cog currently does not have a **bot**, the `app_commands.command` decorator is used instead. The next two lines follow the same structure for Slash Commands with **self** added as the first parameter to the function as it is a method of a class. +- The next up lines are mostly the same. +- Talking about the first line inside the `setup_hook` is the `add_cog` method of the **Bot** class. And since **self** acts as the **instance** of the current class, we use **self** to use the `add_cog` method of the **Bot** class as we are inside a subclassed class of the **Bot** class. Then we pass in **self** to the `add_cog` method as the `__init__` function of the **MySlashCog** cog accepts a `Bot` object. +- After that we instantiate the `MySlashBot` class and run the bot using the **run** method which executes our setup_hook function and our commands get loaded and synced. The bot is now ready to use! + +# An Example to using groups with discord.py for Slash Commands! + +## An example with optional group! + +```python +import discord +from discord.ext import commands +from discord import app_commands + +class MySlashGroupCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + #-------------------------------------------------------- + group = app_commands.Group(name="uwu", description="...") + #-------------------------------------------------------- + + @app_commands.command(name="ping", description="...") + async def _ping(self, interaction: discord.) -> None: + await interaction.response.send_message("pong!") + + @group.command(name="command", description="...") + async def _cmd(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message("uwu") + +class MySlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix="!", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + await self.add_cog(MySlashGroupCog(self)) + await self.tree.copy_global_to(discord.Object(id=123456789098765432)) + await self.tree.sync() + +bot = MySlashBot() + +bot.run("token") +``` + +__**EXPLANATION**__ +- The only difference used here is `group = app_commands.Group(name="uwu", description="...")` and `group.command`. `app_commands.Group` is used to initiate a group while `group.command` registers a command under a group. For example, the ping command can be run using **/ping** but this is not the case for group commands. They are registered with the format of `group_name command_name`. So here, the **command** command of the **uwu** group would be run using **/uwu command**. Note that only group commands can have a single space between them. + +## An example with a **Group** subclass! + +```python +import discord +from discord.ext import commands +from discord import app_commands + +class MySlashGroup(app_commands.Group, name="uwu"): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + super().__init__() + + @app_commands.command(name="ping", description="...") + async def _ping(self, interaction: discord.) -> None: + await interaction.response.send_message("pong!") + + @app_commands.command(name="command", description="...") + async def _cmd(self, interaction: discord.Interaction) -> None: + await interaction.response.send_message("uwu") + +class MySlashBot(commands.Bot): + def __init__(self) -> None: + super().__init__(command_prefix="!", intents=discord.Intents.default()) + + async def setup_hook(self) -> None: + await self.add_cog(MySlashGroup(self)) + await self.tree.copy_global_to(discord.Object(id=123456789098765432)) + await self.tree.sync() + +bot = MySlashBot() + +bot.run("token") +``` + +__**EXPLANATION**__ +- The only difference here too is that the `MySlashGroup` class directly subclasses the **Group** class from discord.app_commands which automatically registers all the methods within the group class to be commands of that specific group. So now, the commands such as `ping` can be run using **/uwu ping** and `command` using **/uwu command**. + +# Some common methods and features used for Slash Commands. + +### A common function used for Slash Commands is the `describe` function. This is used to add descriptions to the arguments of a slash command. The command function can decorated with this function. It goes by the following syntax as shown below. + +```python +from discord.ext import commands +from discord import app_commands +import discord + +bot = commands.Bot(command_prefix=".", intents=discord.Intents.default()) +#sync the commands + [email protected](name="echo", description="...") +@app_commands.describe(text="The text to send!", channel="The channel to send the message in!") +async def _echo(interaction: discord.Interaction, text: str, channel: discord.TextChannel=None): + channel = channel or interaction.channel + await channel.send(text) +``` + +### Another common issue that most people come across is the time duration of sending a message with `send_message`. This issue can be tackled by deferring the interaction response using the `defer` method of the `InteractionResponse` class. An example for fixing this issue is shown below. + +```python +import discord +from discord.ext import commands +import asyncio + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync the commands + [email protected](name="time", description="...") +async def _time(interaction: discord.Interaction, time_to_wait: int): + # ------------------------------------------------------------- + await interaction.response.defer(ephemeral=True) + # ------------------------------------------------------------- + await interaction.edit_original_response(content=f"I will notify you after {time_to_wait} seconds have passed!") + await asyncio.sleep(time_to_wait) + await interaction.edit_original_response(content=f"{interaction.user.mention}, {time_to_wait} seconds have already passed!") +``` + +# Checking for Permissions and Roles! + +To add a permissions check to a command, the methods are imported through `discord.app_commands.checks`. To check for a member's permissions, the function can be decorated with the [discord.app_commands.checks.has_permissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=has_permissions#discord.app_commands.checks.has_permissions) method. An example to this as follows. + +```py +from discord import app_commands +from discord.ext import commands +import discord + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync commands + [email protected](name="ping") +@app_commands.checks.has_permissions(manage_messages=True, manage_channels=True) #example permissions +async def _ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +``` + +If the check fails, it will raise a `MissingPermissions` error which can be handled within an app commands error handler! We will discuss making an error handler later in the Gist. All the permissions can be found [here](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discord%20permissions#discord.Permissions). + +Other methods that you can decorate the commands with are - +- `bot_has_permissions` | This checks if the bot has the required permissions for executing the slash command. This raises a [BotMissingPermissions](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.BotMissingPermissions) exception. +- `has_role` | This checks if the slash command user has the required role or not. Only **ONE** role name or role ID can be passed to this. If the name is being passed, make sure to have the exact same name as the role name. This raises a [MissingRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingRole) exception. +- To pass in several role names or role IDs, `has_any_role` can be used to decorate a command. This raises two exceptions -> [MissingAnyRole](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.MissingAnyRole) and [NoPrivateMessage](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=app_commands%20checks%20has_role#discord.app_commands.NoPrivateMessage) + + +# Adding cooldowns to Slash Commands! + +Slash Commands within discord.py can be applied cooldowns to in order to prevent spamming of the commands. This can be done through the `discord.app_commands.checks.cooldown` method which can be used to decorate a slash command function and register a cooldown to the function. This raises a [CommandOnCooldown](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=checks%20cooldown#discord.app_commands.CommandOnCooldown) exception if the command is currently on cooldown. +An example is as follows. + +```python +from discord.ext import commands +import discord + +class Bot(commands.Bot): + def __init__(self): + super().__init__(command_prefix="uwu", intents=discord.Intents.all()) + + async def setup_hook(self): + self.tree.copy_global_to(guild=discord.Object(id=12345678909876543)) + await self.tree.sync() + + +bot = Bot() + [email protected](name="ping") +# ----------------------------------------- [email protected]_commands.checks.cooldown(1, 30) +# ----------------------------------------- +async def ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +bot.run("token") +``` + +__**EXPLANATION**__ +- The first argument is the number of times this command can be invoked before the cooldown is triggered. +- The second argument it takes is the period of time in which the command can be run the specified number of times. +- The `CommandOnCooldown` exception can be handled using an error handler. We will discuss making an error handler for Slash Commands later in the Gist. + + +# Handling errors for Slash Commands! + +The Slash Commands exceptions can be handled by overwriting the `on_error` method of the `CommandTree`. The error handler takes two arguments. The first argument is the `Interaction` that took place when the error occurred and the second argument is the error that occurred when the Slash Commands was invoked. The error is an instance of [discord.app_commands.AppCommandError](https://discordpy.readthedocs.io/en/latest/interactions/api.html?highlight=appcommanderror#discord.app_commands.AppCommandError) which is a subclass of [DiscordException](https://discordpy.readthedocs.io/en/latest/api.html?highlight=discordexception#discord.DiscordException). +An example to creating an error handler for Slash Commands is as follows. + +```python +from discord.ext import commands +from discord import app_commands +import discord + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync commands + [email protected](name="ping") +@app_commands.checks.cooldown(1, 30) +async def ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +async def on_tree_error(interaction: discord.Interaction, error: app_commands.AppCommandError): + if isinstance(error, app_commands.CommandOnCooldown): + return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!") + elif isinstance(error, ...): + ... + else: + raise error + +bot.tree.on_error = on_tree_error + +bot.run("token") +``` + +__**EXPLANATION**__ + +First we create a simple asynchronous function named `on_tree_error` here. To which the first two required arguments are passed, `Interaction` which is named as `interaction` here and `AppCommandError` which is named as `error` here. Then using simple functions and keywords, we make an error handler like above. Here we have used the `isinstance` function which takes in an object and a base class as the second argument, this function returns a bool value. The `raise error` is just for displaying unhandled errors, i.e. the ones which have not been handled manually. If this is **removed**, you will not be able to see any exceptions raised by Slash Commands and makes debugging the code harder. +After creating the error handler function, we set the function as the error handler for the Slash Commands. Here, `bot.tree.on_error = on_tree_error` overwrites the default `on_error` method of the **CommandTree** class with our custom error handler which has been named as `on_tree_error` here. + +### Creating an error handler for a specific error! + +```python +from discord.ext import commands +from discord import app_commands +import discord + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +#sync commands + [email protected](name="ping") +@app_commands.checks.cooldown(1, 30) +async def ping(interaction: discord.Interaction): + await interaction.response.send_message("pong!") + +async def ping_error(interaction: discord.Interaction, error: app_commands.AppCommandError): + if isinstance(error, app_commands.CommandOnCooldown): + return await interaction.response.send_message(f"Command is currently on cooldown! Try again in **{error.retry_after:.2f}** seconds!") + elif isinstance(error, ...): + ... + else: + raise error + +bot.run("token") +``` + +__**EXPLANATION**__ + +Here the command name is simply used to access the `error` method to decorate a function which acts as the `on_error` but for a specific command. You should not need to call the `error` method manually. 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..0e88490e --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/discord-messages-with-colors.md @@ -0,0 +1,79 @@ +--- +title: Discord Messages with Colors +description: A guide on how to add colors to your codeblocks on Discord +--- + +Discord is now *slowly rolling out* the ability to send colored messages within code blocks. It uses the ANSI color codes, so if you've tried to print colored text in your terminal or console with Python or other languages then it will be easy for you. + +## Quick Explanation +To be able to send a colored text, you need to use the `ansi` language for your code block and provide a prefix of this format before writing your text: +```ansi +\u001b[{format};{color}m +``` +*`\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. + +## Formats +Here is the list of values you can use to replace `{format}`: + +* 0: Normal +* 1: **Bold** +* 4: <ins>Underline</ins> + +## Colors +Here is the list of values you can use to replace `{color}`: + +### Text Colors + +* 30: Gray +* 31: Red +* 32: Green +* 33: Yellow +* 34: Blue +* 35: Pink +* 36: Cyan +* 37: White + +### Background Colors + +* 40: Firefly dark blue +* 41: Orange +* 42: Marble blue +* 43: Greyish turquoise +* 44: Gray +* 45: Indigo +* 46: Light gray +* 47: White + +## Example + +Let's take an example, I want a bold green colored text with the firefly dark blue background. +I simply use `\u001b[0;40m` (background color) and `\u001b[1;32m` (text color) as prefix. Note that the order is **important**, first you give the background color and then the text color. + +Alternatively you can also directly combine them into a single prefix like the following: `\u001b[1;40;32m` and you can also use multiple values. Something like `\u001b[1;40;4;32m` would underline the text, make it bold, make it green and have a dark blue background. + +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: + + + +### ANSI Colors Showcase + +The way the colors look like on Discord is shown in the image below: + + + +*Message sent to get the output of above can be found [here](https://gist.github.com/kkrypt0nn/a02506f3712ff2d1c8ca7c9e0aed7c06#file-ansi-colors-showcase-md).* + +#### Disclaimer + +***Note**: The change has been brought to all stable desktop clients. Since syntax highlighting on mobile is far behind, ANSI is not supported on mobile as well. Refer to [this gist](https://gist.github.com/matthewzring/9f7bbfd102003963f9be7dbcf7d40e51) for other syntax highlighting methods.* diff --git a/pydis_site/apps/content/resources/guides/python-guides/discordpy-subclassing-context.md b/pydis_site/apps/content/resources/guides/python-guides/discordpy-subclassing-context.md new file mode 100644 index 00000000..5e5f05c1 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/discordpy-subclassing-context.md @@ -0,0 +1,129 @@ +--- +title: Subclassing Context in discord.py +description: "Subclassing the default `commands.Context` class to add more functionability and customizability." +--- + +Start by reading the guide on [subclassing the `Bot` class](../subclassing_bot). A subclass of Bot has to be used to +inject your custom context subclass into discord.py. + +## Overview + +The way this works is by creating a subclass of discord.py's [`Context` class](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) +adding whatever functionality you wish. Usually this is done by adding custom methods or properties, so that you don't need to +copy it around or awkwardly import it elsewhere. + +This guide will show you how to add a `prompt()` method to the context and how to use it in a command. + +## Example subclass and code + +The first part - of course - is creating the actual context subclass. This is done similarly to creating a bot +subclass, it will look like this: + +```python +import asyncio +from typing import Optional + +from discord import RawReactionActionEvent +from discord.ext import commands + + +class CustomContext(commands.Context): + async def prompt( + self, + message: str, + *, + timeout=30.0, + delete_after=True + ) -> Optional[bool]: + """Prompt the author with an interactive confirmation message. + + This method will send the `message` content, and wait for max `timeout` seconds + (default is `30`) for the author to react to the message. + + If `delete_after` is `True`, the message will be deleted before returning a + True, False, or None indicating whether the author confirmed, denied, + or didn't interact with the message. + """ + msg = await self.send(message) + + for reaction in ('✅', '❌'): + await msg.add_reaction(reaction) + + confirmation = None + + # This function is a closure because it is defined inside of another + # function. This allows the function to access the self and msg + # variables defined above. + + def check(payload: RawReactionActionEvent): + # 'nonlocal' works almost like 'global' except for functions inside of + # functions. This means that when 'confirmation' is changed, that will + # apply to the variable above + nonlocal confirmation + + if payload.message_id != msg.id or payload.user_id != self.author.id: + return False + + emoji = str(payload.emoji) + + if emoji == '✅': + confirmation = True + return True + + elif emoji == '❌': + confirmation = False + return True + + # This means that it was neither of the two emojis added, so the author + # added some other unrelated reaction. + return False + + try: + await self.bot.wait_for('raw_reaction_add', check=check, timeout=timeout) + except asyncio.TimeoutError: + # The 'confirmation' variable is still None in this case + pass + + if delete_after: + await msg.delete() + + return confirmation +``` + +After creating your context subclass, you need to override the `get_context()` method on your +Bot class and change the default of the `cls` parameter to this subclass: + +```python +from discord.ext import commands + + +class CustomBot(commands.Bot): + async def get_context(self, message, *, cls=CustomContext): # From the above codeblock + return await super().get_context(message, cls=cls) +``` + +Now that discord.py is using your custom context, you can use it in a command. For example: + +```python +import discord +from discord.ext import commands + +# Enable the message intent so that we get message content. This is needed for +# the commands we define below +intents = discord.Intents.default() +intents.message_content = True + + +# Replace '...' with any additional arguments for the bot +bot = CustomBot(intents=intents, ...) + + +async def massban(ctx: CustomContext, members: commands.Greedy[discord.Member]): + prompt = await ctx.prompt(f"Are you sure you want to ban {len(members)} members?") + if not prompt: + # Return if the author cancelled, or didn't react in time + return + + ... # Perform the mass-ban, knowing the author has confirmed this action +``` diff --git a/pydis_site/apps/content/resources/guides/python-guides/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/docker-hosting-guide.md b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md new file mode 100644 index 00000000..7af6a0bb --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/docker-hosting-guide.md @@ -0,0 +1,323 @@ +--- +title: How to host a bot with Docker and GitHub Actions on Ubuntu VPS +description: This guide shows how to host a bot with Docker and GitHub Actions on Ubuntu VPS +--- + +## Contents + +1. [You will learn](#you-will-learn) +2. [Introduction](#introduction) +3. [Installing Docker](#installing-docker) +4. [Creating Dockerfile](#creating-dockerfile) +5. [Building Image and Running Container](#building-image-and-running-container) +6. [Creating Volumes](#creating-volumes) +7. [Using GitHub Actions for full automation](#using-github-actions-for-full-automation) + +## You will learn how to + +- write Dockerfile +- build Docker image and run the container +- use Docker Compose +- make docker keep the files throughout the container's runs +- parse environment variables into container +- use GitHub Actions for automation +- set up self-hosted runner +- use runner secrets + +## Introduction + +Let's say you have got a nice discord bot written in Python and you have a VPS to host it on. Now the only question is +how to run it 24/7. You might have been suggested to use *screen multiplexer*, but it has some disadvantages: + +1. Every time you update the bot you have to SSH to your server, attach to screen, shutdown the bot, run `git pull` and + run the bot again. You might have good extensions management that allows you to update the bot without restarting it, + but there are some other cons as well +2. If you update some dependencies, you have to update them manually +3. The bot doesn't run in an isolated environment, which is not good for security. + +But there's a nice and easy solution to these problems - **Docker**! Docker is a containerization utility that automates +some stuff like dependencies update and running the application in the background. So let's get started. + +## Installing Docker + +The best way to install Docker is to use +the [convenience script](https://docs.docker.com/engine/install/ubuntu/#install-using-the-convenience-script) provided +by Docker developers themselves. You just need 2 lines: + +```shell +$ curl -fsSL https://get.docker.com -o get-docker.sh +$ sudo sh get-docker.sh +``` + +## Creating Dockerfile + +To tell Docker what it has to do to run the application, we need to create a file named `Dockerfile` in our project's +root. + +1. First we need to specify the *base image*, which is the OS that the docker container will be running. Doing that will + make Docker install some apps we need to run our bot, for + example the Python interpreter + +```dockerfile +FROM python:3.10-bullseye +``` + +2. Next, we need to copy our Python project's external dependencies to some directory *inside the container*. Let's call + it `/app` + +```dockerfile +COPY requirements.txt /app/ +``` + +3. Now we need to set the directory as working and install the requirements + +```dockerfile +WORKDIR /app +RUN pip install -r requirements.txt +``` + +4. The only thing that is left to do is to copy the rest of project's files and run the main executable + +```dockerfile +COPY . . +CMD ["python3", "main.py"] +``` + +The final version of Dockerfile looks like this: + +```dockerfile +FROM python:3.10-bullseye +COPY requirements.txt /app/ +WORKDIR /app +RUN pip install -r requirements.txt +COPY . . +CMD ["python3", "main.py"] +``` + +## Building Image and Running Container + +Now update the project on your VPS, so we can run the bot with Docker. + +1. Build the image (dot at the end is very important) + +```shell +$ docker build -t mybot . +``` + +- the `-t` flag specifies a **tag** that will be assigned to the image. With it, we can easily run the image that the + tag was assigned to. +- the dot at the end is basically the path to search for Dockerfile. The dot means current directory (`./`) + +2. Run the container + +```shell +$ docker run -d --name mybot mybot:latest +``` + +- `-d` flag tells Docker to run the container in detached mode, meaning it will run the container in the background of + your terminal and not give us + any output from it. If we don't + provide it, the `run` will be giving us the output until the application exits. Discord bots aren't supposed to exit + after certain time, so we do need this flag +- `--name` assigns a name to the container. By default, container is identified by id that is not human-readable. To + conveniently refer to container when needed, + we can assign it a name +- `mybot:latest` means "latest version of `mybot` image" + +3. Read bot logs (keep in mind that this utility only allows to read STDERR) + +```shell +$ docker logs -f mybot +``` + +- `-f` flag tells the docker to keep reading logs as they appear in container and is called "follow mode". To exit + press `CTRL + C`. + +If everything went successfully, your bot will go online and will keep running! + +## Using Docker Compose + +Just 2 commands to run a container is cool, but we can shorten it down to just 1 simple command. For that, create +a `docker-compose.yml` file in project's root and fill it with the following contents: + +```yml +version: "3.8" +services: + main: + build: . + container_name: mybot +``` + +- `version` tells Docker what version of Compose to use. You may check all the + versions [here](https://docs.docker.com/compose/compose-file/compose-versioning/) +- `services` contains services to build and run. Read more about + services [here](https://docs.docker.com/compose/compose-file/#services-top-level-element) +- `main` is a service. We can call it whatever we would like to, not necessarily `main` +- `build: .` is a path to search for Dockerfile, just like `docker build` command's dot +- `container_name: mybot` is a container name to use for a bot, just like `docker run --name mybot` + +Update the project on VPS, remove the previous container with `docker rm -f mybot` and run this command + +```shell +docker compose up -d --build +``` + +Now the docker will automatically build the image for you and run the container. + +### Why docker-compose + +The main purpose of Compose is to run several services at once. Mostly we +don't need this in discord bots, however. +For us, it has the following benefits: + +- we can build and run the container with just one command +- if we need to parse some environment variables or volumes (more about them further in tutorial) our run command would + look like this + +```shell +$ docker run -d --name mybot -e TOKEN=... -e WEATHER_API_APIKEY=... -e SOME_USEFUL_ENVIRONMENT_VARIABLE=... --net=host -v /home/exenifix/bot-data/data:/app/data -v /home/exenifix/bot-data/images:/app/data/images +``` + +This is pretty long and unreadable. Compose allows us to transfer those flags into single config file and still +use just one short command to run the container. + +## Creating Volumes + +The files creating during container run are destroyed after its recreation. To prevent some files from getting +destroyed, we need to use *volumes* that basically save the files from directory inside of container somewhere on drive. + +1. Create a new directory somewhere and copy path to it + +```shell +$ mkdir mybot-data +$ echo $(pwd)/mybot-data +``` + +My path is `/home/exenifix/mybot-data`, yours is most likely **different**! + +2. In your project, store the files that need to be persistent in a separate directory (eg. `data`) +3. Add `volumes` to `docker-compose.yaml` so it looks like this: + +```yml +version: "3.8" +services: + main: + build: . + container_name: mybot + volumes: + - /home/exenifix/mybot-data:/app/data +``` + +The path before the colon `:` is the directory *on server's drive, outside of container*, and the second path is the +directory *inside of container*. +All the files saved in container in that directory will be saved on drive's directory as well and Docker will be +accessing them *from drive*. + +## Using GitHub Actions for full automation + +Now it's time to fully automate the process and make Docker update the bot automatically on every commit or release. For +that, we will use a **GitHub Actions workflow**, which basically runs some commands when we need to. You may read more +about them [here](https://docs.github.com/en/actions/using-workflows). + +### Create repository secret + +We will not have the ability to use `.env` files with the workflow, so it's better to store the environment variables +as **actions secrets**. Let's add your discord bot's token as a secret + +1. Head to your repository page -> Settings -> Secrets -> Actions +2. Press `New repository secret` +3. Give it a name like `TOKEN` and paste the token. + Now we will be able to access its value in workflow like `${{ secrets.TOKEN }}`. However, we also need to parse the + variable into container now. Edit `docker-compose` so it looks like this: + +```yml +version: "3.8" +services: + main: + build: . + container_name: mybot + volumes: + - /home/exenifix/mybot-data:/app/data + environment: + - TOKEN +``` + +### Setup self-hosted runner + +To run the workflow on our VPS, we will need to register it as *self-hosted runner*. + +1. Head to Settings -> Actions -> Runners +2. Press `New self-hosted runner` +3. Select runner image and architecture +4. Follow the instructions but don't run the runner +5. Instead, create a service + +```shell +$ sudo ./svc.sh install +$ sudo ./svc.sh start +``` + +Now we have registered our VPS as a self-hosted runner and we can run the workflow on it now. + +### Write a workflow + +Create a new file `.github/workflows/runner.yml` and paste the following content into it. Please pay attention to +the `branches` instruction. +The GitHub's standard main branch name is `main`, however it may be named `master` or something else if you edited its +name. Make sure to put +the correct branch name, otherwise it won't work. More about GitHub workflows +syntax [here](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) + +```yml +name: Docker Runner + +on: + push: + branches: [ main ] + +jobs: + run: + runs-on: self-hosted + environment: production + + steps: + - uses: actions/checkout@v3 + + - name: Run Container + run: docker compose up -d --build + env: + TOKEN: ${{ secrets.TOKEN }} + + - name: Cleanup Unused Images + run: docker image prune -f +``` + +Run `docker rm -f mybot` (it only needs to be done once) and push to GitHub. Now if you open `Actions` tab on your +repository, you should see a workflow running your bot. Congratulations! + +### Displaying logs in actions terminal + +There's a nice utility for reading docker container's logs and stopping upon meeting a certain phrase and it might be +useful for you as well. + +1. Install the utility on your VPS with + +```shell +$ pip install exendlr +``` + +2. Add a step to your workflow that would show the logs until it meets `"ready"` phrase. I recommend putting it before + the cleanup. + +```yml +- name: Display Logs + run: python3 -m exendlr mybot "ready" +``` + +Now you should see the logs of your bot until the stop phrase is met. + +**WARNING** +> The utility only reads from STDERR and redirects to STDERR, if you are using STDOUT for logs, it will not work and +> will be waiting for stop phrase forever. The utility automatically exits if bot's container is stopped (e.g. error +> occurred during starting) or if a log line contains a stop phrase. Make sure that your bot 100% displays a stop phrase +> when it's ready otherwise your workflow will get stuck. diff --git a/pydis_site/apps/content/resources/guides/python-guides/fix-ssl-certificate.md b/pydis_site/apps/content/resources/guides/python-guides/fix-ssl-certificate.md new file mode 100644 index 00000000..096e3a90 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/fix-ssl-certificate.md @@ -0,0 +1,23 @@ +--- +title: Fixing an SSL Certificate Verification Error +description: A guide on fixing verification of an SSL certificate. +--- + +We're fixing the error Python specifies as [ssl.SSLCertVerificationError](https://docs.python.org/3/library/ssl.html#ssl.SSLCertVerificationError). + +# How to fix SSL Certificate issue on Windows + +Firstly, try updating your OS, wouldn't hurt to try. + +Now, if you're still having an issue, you would need to download the certificate for the SSL. + +The SSL Certificate, Sectigo (cert vendor) provides a download link of an [SSL certificate](https://crt.sh/?id=2835394). You should find it in the bottom left corner, shown below: + +A picture where to find the certificate in the website is: + + +You have to setup the certificate yourself. To do that you can just click on it, or if that doesn't work, refer to [this link](https://portal.threatpulse.com/docs/sol/Solutions/ManagePolicy/SSL/ssl_chrome_cert_ta.htm) + +# How to fix SSL Certificate issue on Mac + +Navigate to your `Applications/Python 3.x/` folder and double-click the `Install Certificates.command` to fix this. diff --git a/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md new file mode 100644 index 00000000..92eb52a3 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/keeping-tokens-safe.md @@ -0,0 +1,29 @@ +--- +title: Keeping Discord Bot Tokens Safe +description: How to keep your bot tokens safe and safety measures you can take. +--- +It's **very** important to keep a bot token safe, +primarily because anyone who has the bot token can do whatever they want with the bot -- +such as destroying servers your bot has been added to and getting your bot banned from the API. + +# How to Avoid Leaking your Token +To help prevent leaking your token, +you should ensure that you don't upload it to an open source program/website, +such as replit and github, as they show your code publicly. +The best practice for storing tokens is generally utilising .env files +([click here](https://tutorial.vco.sh/tips/tokens/) for more information on storing tokens safely). + +# What should I do if my token does get leaked? + +If for whatever reason your token gets leaked, you should immediately follow these steps: +- Go to the list of [Discord Bot Applications](https://discord.com/developers/applications) you have and select the bot application that had the token leaked. +- Select the Bot (1) tab on the left-hand side, next to a small image of a puzzle piece. After doing so you should see a small section named TOKEN (under your bot USERNAME and next to his avatar image) +- Press the Regenerate button to regenerate your bot token and invalidate the old one. + + + +Following these steps will create a new token for your bot, making it secure again and terminating any connections from the leaked token. +The old token will stop working though, so make sure to replace the old token with the new one in your code if you haven't already. + +# Summary +Make sure you keep your token secure by storing it safely, not sending it to anyone you don't trust, and regenerating your token if it does get leaked. diff --git a/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md new file mode 100644 index 00000000..74b0f59b --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/proper-error-handling.md @@ -0,0 +1,70 @@ +--- +title: Proper error handling in discord.py +description: Are you not getting any errors? This might be why! +--- +If you're not recieving any errors in your console, even though you know you should be, try this: + +# With bot subclass: +```py +import discord +from discord.ext import commands + +import traceback +import sys + +class MyBot(commands.Bot): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + async def on_command_error(self, ctx: commands.Context, error): + # Handle your errors here + if isinstance(error, commands.MemberNotFound): + await ctx.send("I could not find member '{error.argument}'. Please try again") + + elif isinstance(error, commands.MissingRequiredArgument): + await ctx.send(f"'{error.param.name}' is a required argument.") + else: + # All unhandled errors will print their original traceback + print(f'Ignoring exception in command {ctx.command}:', file=sys.stderr) + traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) + +bot = MyBot(command_prefix="!", intents=discord.Intents.default()) + +bot.run("token") +``` + +# Without bot subclass +```py +import discord +from discord.ext import commands + +import traceback +import sys + +async def on_command_error(self, ctx: commands.Context, error): + # Handle your errors here + if isinstance(error, commands.MemberNotFound): + await ctx.send("I could not find member '{error.argument}'. Please try again") + + elif isinstance(error, commands.MissingRequiredArgument): + await ctx.send(f"'{error.param.name}' is a required argument.") + else: + # All unhandled errors will print their original traceback + print(f'Ignoring exception in command {ctx.command}:', file=sys.stderr) + traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +bot.on_command_error = on_command_error + +bot.run("token") +``` + + +Make sure to import `traceback` and `sys`! + +------------------------------------------------------------------------------------------------------------- + +Useful Links: +- [FAQ](https://discordpy.readthedocs.io/en/latest/faq.html) +- [Simple Error Handling](https://gist.github.com/EvieePy/7822af90858ef65012ea500bcecf1612) diff --git a/pydis_site/apps/content/resources/guides/python-guides/setting-different-statuses-on-your-bot.md b/pydis_site/apps/content/resources/guides/python-guides/setting-different-statuses-on-your-bot.md new file mode 100644 index 00000000..45c7b37c --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/setting-different-statuses-on-your-bot.md @@ -0,0 +1,48 @@ +--- +title: Setting Different Statuses on Your Bot +description: How to personalize your Discord bot status +--- + +You've probably seen a bot or two have a status message under their username in the member bar set to something such as `Playing Commands: .help`. + +This guide shows how to set such a status, so your bot can have one as well. + +**Please note:** + +If you want to change the bot status, it is suggested to not do so during the `on_ready` event, since it would be called many times and making an API call on that event has a chance to disconnect the bot. + +The status should not have a problem being set during runtime with `change_presence`, in the examples shown below. + +Instead, set the desired status using the activity / status kwarg of commands.Bot, for example: +```python +bot = commands.Bot(command_prefix="!", activity=..., status=...) +``` + +The following are examples of what you can put into the `activity` keyword argument. + +#### Setting 'Playing' Status +```python +await client.change_presence(activity=discord.Game(name="a game")) +``` + +#### Setting 'Streaming' Status +```python +await client.change_presence(activity=discord.Streaming(name="My Stream", url=my_twitch_url)) +``` + +#### Setting 'Listening' Status +```python +await client.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name="a song")) +``` + +#### Setting 'Watching' Status +```python +await client.change_presence(activity=discord.Activity(type=discord.ActivityType.watching, name="a movie")) +``` + +### Add Optional Status as Well: + +* `discord.Status.online` (default, green icon) +* `discord.Status.idle` (yellow icon) +* `discord.Status.do_not_disturb` (red icon) +* `discord.Status.offline` (gray icon) diff --git a/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md new file mode 100644 index 00000000..8982a4f6 --- /dev/null +++ b/pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md @@ -0,0 +1,58 @@ +--- +title: Subclassing Bot +description: "Subclassing the discord.py `Bot` class to add more functionality and customizability." +--- + +## Basic Subclassing +First, a [basic article](https://www.codesdope.com/course/python-subclass-of-a-class/) on subclassing will provide some fundamental knowledge, which is highly suggested before moving on to this topic, as subclassing [`Bot`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Bot) can ultimately be a complicated task. + +## The Benefits of Subclassing Bot +Subclassing `Bot` can be very beneficial as it provides you with more control and customizability of how your bot functions, also allowing you to add extra features, such as custom bot attributes or methods. For example, the default [Context](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.ext.commands.Context) can be [overridden](../discordpy-subclassing-context) to add more functionality. + +You can subclass `commands.Bot` as shown below: +```python +class CustomBot(commands.Bot): + def __init__(self, *args, **kwargs) -> None: + # Forward all arguments, and keyword-only arguments to commands.Bot + super().__init__(*args, **kwargs) + + # Custom bot attributes can be set here. + self.launch_time = datetime.datetime.utcnow() + self.example_integer = 5 + + # Here you are overriding the default start method and write your own code. + async def start(self, *args, **kwargs) -> None: + """Establish a database connection.""" + self.db = await aiosqlite.connect('sqlite.db') + await super().start(*args, **kwargs) + + # Example of a custom bot method + def get_launch_time_str(self) -> str: + """Get bot launch datetime without milliseconds in UTC and status.""" + return f"Bot started at: {self.launch_time.strftime('%F %T')} UTC." + +# All arguments as passed to commands.Bot can be passed here. +bot = CustomBot( + command_prefix="!", # Prefix can be set to any string. + # Discord intents, refer to https://discordpy.readthedocs.io/en/stable/intents.html + intents=discord.Intents.default() +) + + +# Example bot command +async def start_time(ctx): + """ + Creates a command with the name `start_time`. + + When invoked, sends the output of the custom method `get_launch_time_str`. + """ + await ctx.send(bot.get_launch_time_str()) + + +# Having the token as an environment variable is recommended. +# Refer to https://www.pythondiscord.com/pages/guides/python-guides/keeping-tokens-safe/ +token = YOUR_TOKEN_HERE +bot.run(token) +``` +The next step would be to look into discord.py cogs as they help in organizing collections of commands into various files and folders. Refer to [the official docs](https://discordpy.readthedocs.io/en/stable/ext/commands/cogs.html) for more on them. 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..6d9f433e --- /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.vco.sh/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..e55c6715 100644 --- a/pydis_site/apps/content/resources/rules.md +++ b/pydis_site/apps/content/resources/rules.md @@ -5,26 +5,27 @@ icon: fab fa-discord --- We have a small but strict set of rules on our server. Please read over them and take them on board. If you don't understand a rule or need to report an incident, please send a direct message to <code>@ModMail</code>! -> 1. Follow the [Python Discord Code of Conduct](/pages/code-of-conduct/). +> 1. Follow the [Python Discord Code of Conduct](https://pythondiscord.com/pages/code-of-conduct/). > 2. Follow the [Discord Community Guidelines](https://discordapp.com/guidelines) and [Terms of Service](https://discordapp.com/terms). > 3. Respect staff members and listen to their instructions. > 4. Use English to the best of your ability. Be polite if someone speaks English imperfectly. -> 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. +> 5. Do not provide or request help on projects that may violate terms of service, or that may be deemed inappropriate, malicious, or illegal. +> 6. Do not post unapproved advertising. +> 7. Keep discussions relevant to the channel topic. Each channel's description tells you the topic. > 8. Do not help with ongoing exams. When helping with homework, help people learn how to do the assignment without doing it for them. > 9. Do not offer or ask for paid work of any kind. +> 10. Do not copy and paste answers from ChatGPT or similar AI tools. -# 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..4dadf6b8 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/guides/pydis-guides/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. @@ -121,9 +125,7 @@ Being a helper is also more than just quantity of messages, it's about quality. # Miscellaneous Roles ### <span class="fas fa-circle" style="color:#9f3fee"></span> Partners -**Description:** Representatives of communities we are partnered with. For a list of partnered communities, see the `#partners` channel. - -*Note: Not related to [Discord Partners](https://discordapp.com/partners), which our server is currently a part of.* +**Description:** Representatives of communities we are partnered with. ### <span class="fas fa-circle" style="color:#c77cfa"></span> Python Community **Description:** Prominent people in the Python ecosystem. diff --git a/pydis_site/apps/content/resources/tags/_info.yml b/pydis_site/apps/content/resources/tags/_info.yml new file mode 100644 index 00000000..054125ec --- /dev/null +++ b/pydis_site/apps/content/resources/tags/_info.yml @@ -0,0 +1,3 @@ +title: Tags +description: Useful snippets that are often used in the server. +icon: fas fa-tags diff --git a/pydis_site/apps/admin/__init__.py b/pydis_site/apps/content/templatetags/__init__.py index e69de29b..e69de29b 100644 --- a/pydis_site/apps/admin/__init__.py +++ b/pydis_site/apps/content/templatetags/__init__.py diff --git a/pydis_site/apps/content/templatetags/page_src.py b/pydis_site/apps/content/templatetags/page_src.py new file mode 100644 index 00000000..143c420c --- /dev/null +++ b/pydis_site/apps/content/templatetags/page_src.py @@ -0,0 +1,25 @@ +from django import template +from django.conf import settings + + +register = template.Library() + + +def page_src_url(request_path: str) -> str: + """ + Return the corresponding GitHub source URL for the current content article. + + request_path is the relative path of an article, as returned by `request.path` in templates. + + GitHub source URL is set in settings.py as CONTENT_SRC_URL, prefix for the + url which the request path would be appended to. + + Assumes '.md' file extension for the page source files. + + For example: /pages/rules/ would return: + https://github.com/python-discord/site/tree/main/pydis_site/apps/content/resources/rules.md + """ + src_url = request_path.replace("/pages/", settings.CONTENT_SRC_URL) + src_url = src_url[:-1] + ".md" + return src_url diff --git a/pydis_site/apps/content/tests/helpers.py b/pydis_site/apps/content/tests/helpers.py index d897c024..0e7562e8 100644 --- a/pydis_site/apps/content/tests/helpers.py +++ b/pydis_site/apps/content/tests/helpers.py @@ -1,12 +1,13 @@ +import atexit +import shutil +import tempfile from pathlib import Path -from pyfakefs import fake_filesystem_unittest +from django.test import TestCase -# Set the module constant within Patcher to use the fake filesystem -# https://jmcgeheeiv.github.io/pyfakefs/master/usage.html#modules-to-reload -with fake_filesystem_unittest.Patcher() as _: - BASE_PATH = Path("res") +BASE_PATH = Path(tempfile.mkdtemp(prefix='pydis-site-content-app-tests-')) +atexit.register(shutil.rmtree, BASE_PATH, ignore_errors=True) # Valid markdown content with YAML metadata @@ -50,7 +51,7 @@ PARSED_METADATA = { PARSED_CATEGORY_INFO = {"title": "Category Name", "description": "Description"} -class MockPagesTestCase(fake_filesystem_unittest.TestCase): +class MockPagesTestCase(TestCase): """ TestCase with a fake filesystem for testing. @@ -61,43 +62,41 @@ class MockPagesTestCase(fake_filesystem_unittest.TestCase): ├── not_a_page.md ├── tmp.md ├── tmp - | ├── _info.yml - | └── category - | ├── _info.yml - | └── subcategory_without_info + | ├── _info.yml + | └── category + | ├── _info.yml + | └── subcategory_without_info └── category - ├── _info.yml - ├── with_metadata.md - └── subcategory - ├── with_metadata.md - └── without_metadata.md + ├── _info.yml + ├── with_metadata.md + └── subcategory + ├── with_metadata.md + └── without_metadata.md """ - def setUp(self): + def setUp(self) -> None: """Create the fake filesystem.""" - self.setUpPyfakefs() - - self.fs.create_file(f"{BASE_PATH}/_info.yml", contents=CATEGORY_INFO) - self.fs.create_file(f"{BASE_PATH}/root.md", contents=MARKDOWN_WITH_METADATA) - self.fs.create_file( - f"{BASE_PATH}/root_without_metadata.md", contents=MARKDOWN_WITHOUT_METADATA - ) - self.fs.create_file(f"{BASE_PATH}/not_a_page.md/_info.yml", contents=CATEGORY_INFO) - self.fs.create_file(f"{BASE_PATH}/category/_info.yml", contents=CATEGORY_INFO) - self.fs.create_file( - f"{BASE_PATH}/category/with_metadata.md", contents=MARKDOWN_WITH_METADATA - ) - self.fs.create_file(f"{BASE_PATH}/category/subcategory/_info.yml", contents=CATEGORY_INFO) - self.fs.create_file( - f"{BASE_PATH}/category/subcategory/with_metadata.md", contents=MARKDOWN_WITH_METADATA - ) - self.fs.create_file( - f"{BASE_PATH}/category/subcategory/without_metadata.md", - contents=MARKDOWN_WITHOUT_METADATA - ) + Path(f"{BASE_PATH}/_info.yml").write_text(CATEGORY_INFO) + Path(f"{BASE_PATH}/root.md").write_text(MARKDOWN_WITH_METADATA) + Path(f"{BASE_PATH}/root_without_metadata.md").write_text(MARKDOWN_WITHOUT_METADATA) + Path(f"{BASE_PATH}/not_a_page.md").mkdir(exist_ok=True) + Path(f"{BASE_PATH}/not_a_page.md/_info.yml").write_text(CATEGORY_INFO) + Path(f"{BASE_PATH}/category").mkdir(exist_ok=True) + Path(f"{BASE_PATH}/category/_info.yml").write_text(CATEGORY_INFO) + Path(f"{BASE_PATH}/category/with_metadata.md").write_text(MARKDOWN_WITH_METADATA) + Path(f"{BASE_PATH}/category/subcategory").mkdir(exist_ok=True) + Path(f"{BASE_PATH}/category/subcategory/_info.yml").write_text(CATEGORY_INFO) + Path( + f"{BASE_PATH}/category/subcategory/with_metadata.md" + ).write_text(MARKDOWN_WITH_METADATA) + Path( + f"{BASE_PATH}/category/subcategory/without_metadata.md" + ).write_text(MARKDOWN_WITHOUT_METADATA) temp = f"{BASE_PATH}/tmp" # noqa: S108 - self.fs.create_file(f"{temp}/_info.yml", contents=CATEGORY_INFO) - self.fs.create_file(f"{temp}.md", contents=MARKDOWN_WITH_METADATA) - self.fs.create_file(f"{temp}/category/_info.yml", contents=CATEGORY_INFO) - self.fs.create_dir(f"{temp}/category/subcategory_without_info") + Path(f"{temp}").mkdir(exist_ok=True) + Path(f"{temp}/_info.yml").write_text(CATEGORY_INFO) + Path(f"{temp}.md").write_text(MARKDOWN_WITH_METADATA) + Path(f"{temp}/category").mkdir(exist_ok=True) + Path(f"{temp}/category/_info.yml").write_text(CATEGORY_INFO) + Path(f"{temp}/category/subcategory_without_info").mkdir(exist_ok=True) diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py index be5ea897..7f7736f9 100644 --- a/pydis_site/apps/content/tests/test_utils.py +++ b/pydis_site/apps/content/tests/test_utils.py @@ -1,12 +1,34 @@ +import datetime +import json +import tarfile +import tempfile +import textwrap from pathlib import Path +from unittest import mock +import httpx +import markdown from django.http import Http404 +from django.test import TestCase -from pydis_site.apps.content import utils +from pydis_site import settings +from pydis_site.apps.content import models, utils from pydis_site.apps.content.tests.helpers import ( BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA ) +_time = datetime.datetime(2022, 10, 10, 10, 10, 10, tzinfo=datetime.UTC) +_time_str = _time.strftime(settings.GITHUB_TIMESTAMP_FORMAT) +TEST_COMMIT_KWARGS = { + "sha": "123", + "message": "Hello world\n\nThis is a commit message", + "date": _time, + "authors": json.dumps([ + {"name": "Author 1", "email": "[email protected]", "date": _time_str}, + {"name": "Author 2", "email": "[email protected]", "date": _time_str}, + ]), +} + class GetCategoryTests(MockPagesTestCase): """Tests for the get_category function.""" @@ -96,3 +118,268 @@ class GetPageTests(MockPagesTestCase): def test_get_nonexistent_page_returns_404(self): with self.assertRaises(Http404): utils.get_page(Path(BASE_PATH, "invalid")) + + +class TagUtilsTests(TestCase): + """Tests for the tag-related utilities.""" + + def setUp(self) -> None: + super().setUp() + self.commit = models.Commit.objects.create(**TEST_COMMIT_KWARGS) + + @mock.patch.object(utils, "fetch_tags") + def test_static_fetch(self, fetch_mock: mock.Mock): + """Test that the static fetch function is only called at most once during static builds.""" + tags = [models.Tag(name="Name", body="body")] + fetch_mock.return_value = tags + result = utils.get_tags_static() + second_result = utils.get_tags_static() + + fetch_mock.assert_called_once() + self.assertEqual(tags, result) + self.assertEqual(tags, second_result) + + @mock.patch("httpx.Client.get") + def test_mocked_fetch(self, get_mock: mock.Mock): + """Test that proper data is returned from fetch, but with a mocked API response.""" + fake_request = httpx.Request("GET", "https://google.com") + + # Metadata requests + returns = [httpx.Response( + request=fake_request, + status_code=200, + json=[ + {"type": "file", "name": "first_tag.md", "sha": "123"}, + {"type": "file", "name": "second_tag.md", "sha": "456"}, + {"type": "dir", "name": "some_group", "sha": "789", "url": "/some_group"}, + ] + ), httpx.Response( + request=fake_request, + status_code=200, + json=[{"type": "file", "name": "grouped_tag.md", "sha": "789123"}] + )] + + # Main content request + bodies = ( + "This is the first tag!", + textwrap.dedent(""" + --- + frontmatter: empty + --- + This tag has frontmatter! + """), + "This is a grouped tag!", + ) + + # Generate a tar archive with a few tags + with tempfile.TemporaryDirectory() as tar_folder: + tar_folder = Path(tar_folder) + with tempfile.TemporaryDirectory() as folder: + folder = Path(folder) + (folder / "ignored_file.md").write_text("This is an ignored file.") + tags_folder = folder / "bot/resources/tags" + tags_folder.mkdir(parents=True) + + (tags_folder / "first_tag.md").write_text(bodies[0]) + (tags_folder / "second_tag.md").write_text(bodies[1]) + + group_folder = tags_folder / "some_group" + group_folder.mkdir() + (group_folder / "grouped_tag.md").write_text(bodies[2]) + + with tarfile.open(tar_folder / "temp.tar", "w") as file: + file.add(folder, recursive=True) + + body = (tar_folder / "temp.tar").read_bytes() + + returns.append(httpx.Response( + status_code=200, + content=body, + request=fake_request, + )) + + get_mock.side_effect = returns + result = utils.fetch_tags() + + def sort(_tag: models.Tag) -> str: + return _tag.name + + self.assertEqual(sorted([ + models.Tag(name="first_tag", body=bodies[0], sha="123"), + models.Tag(name="second_tag", body=bodies[1], sha="245"), + models.Tag(name="grouped_tag", body=bodies[2], group=group_folder.name, sha="789123"), + ], key=sort), sorted(result, key=sort)) + + def test_get_real_tag(self): + """Test that a single tag is returned if it exists.""" + tag = models.Tag.objects.create(name="real-tag", last_commit=self.commit) + result = utils.get_tag("real-tag") + + self.assertEqual(tag, result) + + def test_get_grouped_tag(self): + """Test fetching a tag from a group.""" + tag = models.Tag.objects.create( + name="real-tag", group="real-group", last_commit=self.commit + ) + result = utils.get_tag("real-group/real-tag") + + self.assertEqual(tag, result) + + def test_get_group(self): + """Test fetching a group of tags.""" + included = [ + models.Tag.objects.create(name="tag-1", group="real-group"), + models.Tag.objects.create(name="tag-2", group="real-group"), + models.Tag.objects.create(name="tag-3", group="real-group"), + ] + + models.Tag.objects.create(name="not-included-1") + models.Tag.objects.create(name="not-included-2", group="other-group") + + result = utils.get_tag("real-group") + self.assertListEqual(included, result) + + def test_get_tag_404(self): + """Test that an error is raised when we fetch a non-existing tag.""" + models.Tag.objects.create(name="real-tag") + with self.assertRaises(models.Tag.DoesNotExist): + utils.get_tag("fake") + + @mock.patch.object(utils, "get_tag_category") + def test_category_pages(self, get_mock: mock.Mock): + """Test that the category pages function calls the correct method for tags.""" + tag = models.Tag.objects.create(name="tag") + get_mock.return_value = tag + result = utils.get_category_pages(settings.CONTENT_PAGES_PATH / "tags") + self.assertEqual(tag, result) + get_mock.assert_called_once_with(collapse_groups=True) + + def test_get_category_root(self): + """Test that all tags are returned and formatted properly for the tag root page.""" + body = "normal body" + base = {"description": markdown.markdown(body), "icon": "fas fa-tag"} + + models.Tag.objects.create(name="tag-1", body=body), + models.Tag.objects.create(name="tag-2", body=body), + models.Tag.objects.create(name="tag-3", body=body), + + models.Tag.objects.create(name="tag-4", body=body, group="tag-group") + models.Tag.objects.create(name="tag-5", body=body, group="tag-group") + + result = utils.get_tag_category(collapse_groups=True) + + self.assertDictEqual({ + "tag-1": {**base, "title": "tag-1"}, + "tag-2": {**base, "title": "tag-2"}, + "tag-3": {**base, "title": "tag-3"}, + "tag-group": { + "title": "tag-group", + "description": "Contains the following tags: tag-4, tag-5", + "icon": "fas fa-tags" + } + }, result) + + def test_get_category_group(self): + """Test the function for a group root page.""" + body = "normal body" + base = {"description": markdown.markdown(body), "icon": "fas fa-tag"} + + included = [ + models.Tag.objects.create(name="tag-1", body=body, group="group"), + models.Tag.objects.create(name="tag-2", body=body, group="group"), + ] + models.Tag.objects.create(name="not-included", body=body) + + result = utils.get_tag_category(included, collapse_groups=False) + self.assertDictEqual({ + "tag-1": {**base, "title": "tag-1"}, + "tag-2": {**base, "title": "tag-2"}, + }, result) + + def test_tag_url(self): + """Test that tag URLs are generated correctly.""" + cases = [ + ({"name": "tag"}, f"{models.Tag.URL_BASE}/tag.md"), + ({"name": "grouped", "group": "abc"}, f"{models.Tag.URL_BASE}/abc/grouped.md"), + ] + + for options, url in cases: + tag = models.Tag(**options) + with self.subTest(tag=tag): + self.assertEqual(url, tag.url) + + @mock.patch("httpx.Client.get") + def test_get_tag_commit(self, get_mock: mock.Mock): + """Test the get commit function with a normal tag.""" + tag = models.Tag.objects.create(name="example") + + authors = json.loads(self.commit.authors) + + get_mock.return_value = httpx.Response( + request=httpx.Request("GET", "https://google.com"), + status_code=200, + json=[{ + "sha": self.commit.sha, + "commit": { + "message": self.commit.message, + "author": authors[0], + "committer": authors[1], + } + }] + ) + + result = utils.get_tag(tag.name) + self.assertEqual(tag, result) + + get_mock.assert_called_once() + call_params = get_mock.call_args[1]["params"] + + self.assertEqual({"path": "/bot/resources/tags/example.md"}, call_params) + self.assertEqual(self.commit, models.Tag.objects.get(name=tag.name).last_commit) + + @mock.patch("httpx.Client.get") + def test_get_group_tag_commit(self, get_mock: mock.Mock): + """Test the get commit function with a group tag.""" + tag = models.Tag.objects.create(name="example", group="group-name") + + authors = json.loads(self.commit.authors) + authors.pop() + self.commit.authors = json.dumps(authors) + self.commit.save() + + get_mock.return_value = httpx.Response( + request=httpx.Request("GET", "https://google.com"), + status_code=200, + json=[{ + "sha": self.commit.sha, + "commit": { + "message": self.commit.message, + "author": authors[0], + "committer": authors[0], + } + }] + ) + + utils.set_tag_commit(tag) + + get_mock.assert_called_once() + call_params = get_mock.call_args[1]["params"] + + self.assertEqual({"path": "/bot/resources/tags/group-name/example.md"}, call_params) + self.assertEqual(self.commit, models.Tag.objects.get(name=tag.name).last_commit) + + @mock.patch.object(utils, "set_tag_commit") + def test_exiting_commit(self, set_commit_mock: mock.Mock): + """Test that a commit is saved when the data has not changed.""" + tag = models.Tag.objects.create(name="tag-name", body="old body", last_commit=self.commit) + + # This is only applied to the object, not to the database + tag.last_commit = None + + utils.record_tags([tag]) + self.assertEqual(self.commit, tag.last_commit) + + result = utils.get_tag("tag-name") + self.assertEqual(tag, result) + set_commit_mock.assert_not_called() diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py index eadad7e3..cfc580c0 100644 --- a/pydis_site/apps/content/tests/test_views.py +++ b/pydis_site/apps/content/tests/test_views.py @@ -1,12 +1,18 @@ +import textwrap from pathlib import Path -from unittest import TestCase +from unittest import TestCase, mock +import django.test +import markdown from django.http import Http404 from django.test import RequestFactory, SimpleTestCase, override_settings +from django.urls import reverse +from pydis_site.apps.content.models import Commit, Tag from pydis_site.apps.content.tests.helpers import ( BASE_PATH, MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA ) +from pydis_site.apps.content.tests.test_utils import TEST_COMMIT_KWARGS from pydis_site.apps.content.views import PageOrCategoryView @@ -172,7 +178,7 @@ class PageOrCategoryViewTests(MockPagesTestCase, SimpleTestCase, TestCase): for item in context["breadcrumb_items"]: item["path"] = Path(item["path"]) - self.assertEquals( + self.assertEqual( context["breadcrumb_items"], [ {"name": PARSED_CATEGORY_INFO["title"], "path": Path(".")}, @@ -180,3 +186,226 @@ class PageOrCategoryViewTests(MockPagesTestCase, SimpleTestCase, TestCase): {"name": PARSED_CATEGORY_INFO["title"], "path": Path("category/subcategory")}, ] ) + + +class TagViewTests(django.test.TestCase): + """Tests for the TagView class.""" + + def setUp(self): + """Set test helpers, then set up fake filesystem.""" + super().setUp() + self.commit = Commit.objects.create(**TEST_COMMIT_KWARGS) + + def test_routing(self): + """Test that the correct template is returned for each route.""" + Tag.objects.create(name="example", last_commit=self.commit) + Tag.objects.create(name="grouped-tag", group="group-name", last_commit=self.commit) + + cases = [ + ("/pages/tags/example/", "content/tag.html"), + ("/pages/tags/group-name/", "content/listing.html"), + ("/pages/tags/group-name/grouped-tag/", "content/tag.html"), + ] + + for url, template in cases: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(200, response.status_code) + self.assertTemplateUsed(response, template) + + def test_valid_tag_returns_200(self): + """Test that a page is returned for a valid tag.""" + Tag.objects.create(name="example", body="This is the tag body.", last_commit=self.commit) + response = self.client.get("/pages/tags/example/") + self.assertEqual(200, response.status_code) + self.assertIn("This is the tag body", response.content.decode("utf-8")) + self.assertTemplateUsed(response, "content/tag.html") + + def test_invalid_tag_404(self): + """Test that a tag which doesn't exist raises a 404.""" + with mock.patch("pydis_site.apps.content.utils.fetch_tags", autospec=True): + response = self.client.get("/pages/tags/non-existent/") + self.assertEqual(404, response.status_code) + + def test_context_tag(self): + """Test that the context contains the required data for a tag.""" + body = textwrap.dedent(""" + --- + unused: frontmatter + ---- + Tag content here. + """) + + tag = Tag.objects.create(name="example", body=body, last_commit=self.commit) + response = self.client.get("/pages/tags/example/") + expected = { + "page_title": "example", + "page": markdown.markdown("Tag content here."), + "tag": tag, + "breadcrumb_items": [ + {"name": "Pages", "path": "."}, + {"name": "Tags", "path": "tags"}, + ] + } + for key in expected: + self.assertEqual( + expected[key], response.context.get(key), f"context.{key} did not match" + ) + + def test_context_grouped_tag(self): + """ + Test the context for a tag in a group. + + The only difference between this and a regular tag are the breadcrumbs, + so only those are checked. + """ + Tag.objects.create( + name="example", body="Body text", group="group-name", last_commit=self.commit + ) + response = self.client.get("/pages/tags/group-name/example/") + self.assertListEqual([ + {"name": "Pages", "path": "."}, + {"name": "Tags", "path": "tags"}, + {"name": "group-name", "path": "tags/group-name"}, + ], response.context.get("breadcrumb_items")) + + def test_group_page(self): + """Test rendering of a group's root page.""" + Tag.objects.create(name="tag-1", body="Body 1", group="group-name", last_commit=self.commit) + Tag.objects.create(name="tag-2", body="Body 2", group="group-name", last_commit=self.commit) + Tag.objects.create(name="not-included", last_commit=self.commit) + + response = self.client.get("/pages/tags/group-name/") + content = response.content.decode("utf-8") + + self.assertInHTML("<div class='level-left'>group-name</div>", content) + self.assertInHTML( + f"<a class='level-item fab fa-github' href='{Tag.URL_BASE}/group-name'>", + content + ) + self.assertIn(">tag-1</span>", content) + self.assertIn(">tag-2</span>", content) + self.assertNotIn( + ">not-included</span>", + content, + "Tags not in this group shouldn't be rendered." + ) + + self.assertInHTML("<p>Body 1</p>", content) + + def test_markdown(self): + """Test that markdown content is rendered properly.""" + body = textwrap.dedent(""" + ```py + Hello world! + ``` + + **This text is in bold** + """) + + Tag.objects.create(name="example", body=body, last_commit=self.commit) + response = self.client.get("/pages/tags/example/") + content = response.content.decode("utf-8") + + self.assertInHTML('<code class="language-py">Hello world!</code>', content) + self.assertInHTML("<strong>This text is in bold</strong>", content) + + def test_embed(self): + """Test that an embed from the frontmatter is treated correctly.""" + body = textwrap.dedent(""" + --- + embed: + title: Embed title + image: + url: https://google.com + --- + Tag body. + """) + + Tag.objects.create(name="example", body=body, last_commit=self.commit) + response = self.client.get("/pages/tags/example/") + content = response.content.decode("utf-8") + + self.assertInHTML('<img alt="Embed title" src="https://google.com"/>', content) + self.assertInHTML("<p>Tag body.</p>", content) + + def test_embed_title(self): + """Test that the page title gets set to the embed title.""" + body = textwrap.dedent(""" + --- + embed: + title: Embed title + --- + """) + + Tag.objects.create(name="example", body=body, last_commit=self.commit) + response = self.client.get("/pages/tags/example/") + self.assertEqual( + "Embed title", + response.context.get("page_title"), + "The page title must match the embed title." + ) + + def test_hyperlinked_item(self): + """Test hyperlinking of tags works as intended.""" + filler_before, filler_after = "empty filler text\n\n", "more\nfiller" + body = filler_before + "`!tags return`" + filler_after + Tag.objects.create(name="example", body=body, last_commit=self.commit) + + other_url = reverse("content:tag", kwargs={"location": "return"}) + response = self.client.get("/pages/tags/example/") + self.assertEqual( + markdown.markdown(filler_before + f"[`!tags return`]({other_url})" + filler_after), + response.context.get("page") + ) + + def test_hyperlinked_group(self): + """Test hyperlinking with a group works as intended.""" + Tag.objects.create( + name="example", body="!tags group-name grouped-tag", last_commit=self.commit + ) + Tag.objects.create(name="grouped-tag", group="group-name") + + other_url = reverse("content:tag", kwargs={"location": "group-name/grouped-tag"}) + response = self.client.get("/pages/tags/example/") + self.assertEqual( + markdown.markdown(f"[!tags group-name grouped-tag]({other_url})"), + response.context.get("page") + ) + + def test_hyperlinked_extra_text(self): + """Test hyperlinking when a tag is followed by extra, unrelated text.""" + Tag.objects.create( + name="example", body="!tags other unrelated text", last_commit=self.commit + ) + Tag.objects.create(name="other") + + other_url = reverse("content:tag", kwargs={"location": "other"}) + response = self.client.get("/pages/tags/example/") + self.assertEqual( + markdown.markdown(f"[!tags other]({other_url}) unrelated text"), + response.context.get("page") + ) + + def test_tags_have_no_edit_on_github_link(self): + """Tags should not have the standard edit on GitHub link.""" + # The standard "Edit on GitHub" link should not be displayed on tags + # because they have their own GitHub icon that links there. + Tag.objects.create(name="example", body="Joe William Banks", last_commit=self.commit) + response = self.client.get("/pages/tags/example/") + self.assertNotContains(response, "Edit on GitHub") + + def test_tag_root_page(self): + """Test the root tag page which lists all tags.""" + Tag.objects.create(name="tag-1", last_commit=self.commit) + Tag.objects.create(name="tag-2", last_commit=self.commit) + Tag.objects.create(name="tag-3", last_commit=self.commit) + + response = self.client.get("/pages/tags/") + content = response.content.decode("utf-8") + + self.assertTemplateUsed(response, "content/listing.html") + self.assertInHTML('<div class="level-left">Tags</div>', content) + + for tag_number in range(1, 4): + self.assertIn(f"tag-{tag_number}</span>", content) diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py index f8496095..baae154d 100644 --- a/pydis_site/apps/content/urls.py +++ b/pydis_site/apps/content/urls.py @@ -3,12 +3,12 @@ from pathlib import Path from django_distill import distill_path -from . import views +from . import utils, views app_name = "content" -def __get_all_files(root: Path, folder: typing.Optional[Path] = None) -> list[str]: +def __get_all_files(root: Path, folder: Path | None = None) -> list[str]: """Find all folders and markdown files recursively starting from `root`.""" if not folder: folder = root @@ -29,15 +29,38 @@ def __get_all_files(root: Path, folder: typing.Optional[Path] = None) -> list[st return results -def get_all_pages() -> typing.Iterator[dict[str, str]]: +DISTILL_RETURN = typing.Iterator[dict[str, str]] + + +def get_all_pages() -> DISTILL_RETURN: """Yield a dict of all page categories.""" for location in __get_all_files(Path("pydis_site", "apps", "content", "resources")): yield {"location": location} +def get_all_tags() -> DISTILL_RETURN: + """Return all tag names and groups in static builds.""" + # We instantiate the set with None here to make filtering it out later easier + # whether it was added in the loop or not + groups = {None} + for tag in utils.get_tags_static(): + groups.add(tag.group) + yield {"location": (f"{tag.group}/" if tag.group else "") + tag.name} + + groups.remove(None) + for group in groups: + yield {"location": group} + + urlpatterns = [ distill_path("", views.PageOrCategoryView.as_view(), name='pages'), distill_path( + "tags/<path:location>/", + views.TagView.as_view(), + name="tag", + distill_func=get_all_tags + ), + distill_path( "<path:location>/", views.PageOrCategoryView.as_view(), name='page_category', diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py index d3f270ff..cfd73d67 100644 --- a/pydis_site/apps/content/utils.py +++ b/pydis_site/apps/content/utils.py @@ -1,14 +1,44 @@ +import datetime +import functools +import json +import logging +import tarfile +import tempfile +from http import HTTPStatus +from io import BytesIO from pathlib import Path -from typing import Dict, Tuple import frontmatter +import httpx import markdown import yaml from django.http import Http404 +from django.utils import timezone from markdown.extensions.toc import TocExtension +from pydis_site import settings +from .models import Commit, Tag -def get_category(path: Path) -> Dict[str, str]: +TAG_CACHE_TTL = datetime.timedelta(hours=1) +log = logging.getLogger(__name__) + + +def github_client(**kwargs) -> httpx.Client: + """Get a client to access the GitHub API with important settings pre-configured.""" + client = httpx.Client( + base_url=settings.GITHUB_API, + follow_redirects=True, + timeout=settings.TIMEOUT_PERIOD, + **kwargs + ) + if settings.GITHUB_TOKEN: # pragma: no cover + if not client.headers.get("Authorization"): + client.headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"} + + return client + + +def get_category(path: Path) -> dict[str, str]: """Load category information by name from _info.yml.""" if not path.is_dir(): raise Http404("Category not found.") @@ -16,7 +46,7 @@ def get_category(path: Path) -> Dict[str, str]: return yaml.safe_load(path.joinpath("_info.yml").read_text(encoding="utf-8")) -def get_categories(path: Path) -> Dict[str, Dict]: +def get_categories(path: Path) -> dict[str, dict]: """Get information for all categories.""" categories = {} @@ -27,8 +57,272 @@ def get_categories(path: Path) -> Dict[str, Dict]: return categories -def get_category_pages(path: Path) -> Dict[str, Dict]: +def get_tags_static() -> list[Tag]: + """ + Fetch tag information in static builds. + + This also includes some fake tags to preview the tag groups feature. + This will return a cached value, so it should only be used for static builds. + """ + tags = fetch_tags() + for tag in tags[3:5]: # pragma: no cover + tag.group = "very-cool-group" + return tags + + +def fetch_tags() -> list[Tag]: + """ + Fetch tag data from the GitHub API. + + The entire repository is downloaded and extracted locally because + getting file content would require one request per file, and can get rate-limited. + """ + with github_client() as client: + # Grab metadata + metadata = client.get("/repos/python-discord/bot/contents/bot/resources") + metadata.raise_for_status() + + hashes = {} + for entry in metadata.json(): + if entry["type"] == "dir": + # Tag group + files = client.get(entry["url"]) + files.raise_for_status() + files = files.json() + else: + files = [entry] + + for file in files: + hashes[file["name"]] = file["sha"] + + # Download the files + tar_file = client.get("/repos/python-discord/bot/tarball") + tar_file.raise_for_status() + + tags = [] + with tempfile.TemporaryDirectory() as folder: + with tarfile.open(fileobj=BytesIO(tar_file.content)) as repo: + included = [] + for file in repo.getmembers(): + if "/bot/resources/tags" in file.path: + included.append(file) + repo.extractall(folder, included) + + for tag_file in Path(folder).rglob("*.md"): + name = tag_file.name + group = None + if tag_file.parent.name != "tags": + # Tags in sub-folders are considered part of a group + group = tag_file.parent.name + + tags.append(Tag( + name=name.removesuffix(".md"), + sha=hashes[name], + group=group, + body=tag_file.read_text(encoding="utf-8"), + last_commit=None, + )) + + return tags + + +def set_tag_commit(tag: Tag) -> None: + """Fetch commit information from the API, and save it for the tag.""" + if settings.STATIC_BUILD: # pragma: no cover + # Static builds request every page during build, which can ratelimit it. + # Instead, we return some fake data. + tag.last_commit = Commit( + sha="68da80efc00d9932a209d5cccd8d344cec0f09ea", + message="Initial Commit\n\nTHIS IS FAKE DEMO DATA", + date=datetime.datetime(2018, 2, 3, 12, 20, 26, tzinfo=datetime.UTC), + authors=json.dumps([{"name": "Joseph", "email": "[email protected]"}]), + ) + return + + path = "/bot/resources/tags" + if tag.group: + path += f"/{tag.group}" + path += f"/{tag.name}.md" + + # Fetch and set the commit + with github_client() as client: + response = client.get("/repos/python-discord/bot/commits", params={"path": path}) + if ( + # We want to hop out early in three cases: + # - We got a forbidden response. (GitHub wrongfully uses this for rate limits.) + # - We got ratelimited. + response.status_code in (HTTPStatus.FORBIDDEN, HTTPStatus.TOO_MANY_REQUESTS) + # - GitHub has unicorn time again and is returning 5xx codes. + or int(response.status_code / 100) == 5 + ): # pragma: no cover + log.warning( + "Received code %d from GitHub for commit history for bot file %r", + response.status_code, path, + ) + # We hop out early because otherwise, these failures may result in the + # overall request to the tag page breaking. + return + + # This should only be permanent issues from here, such as bad requests. + response.raise_for_status() + data = response.json()[0] + + commit = data["commit"] + author, committer = commit["author"], commit["committer"] + + date = ( + datetime.datetime + .strptime(committer["date"], settings.GITHUB_TIMESTAMP_FORMAT) + .replace(tzinfo=datetime.UTC) + ) + + if author["email"] == committer["email"]: + authors = [author] + else: + authors = [author, committer] + + commit_obj, _ = Commit.objects.get_or_create( + sha=data["sha"], + message=commit["message"], + date=date, + authors=json.dumps(authors), + ) + tag.last_commit = commit_obj + tag.save() + + +def record_tags(tags: list[Tag]) -> None: + """Sync the database with an updated set of tags.""" + # Remove entries which no longer exist + Tag.objects.exclude(name__in=[tag.name for tag in tags]).delete() + + # Insert/update the tags + for new_tag in tags: + try: + old_tag = Tag.objects.get(name=new_tag.name) + except Tag.DoesNotExist: + # The tag is not in the database yet, + # pretend it's previous state is the current state + old_tag = new_tag + + if old_tag.sha == new_tag.sha and old_tag.last_commit is not None: + # We still have an up-to-date commit entry + new_tag.last_commit = old_tag.last_commit + + new_tag.save() + + # Drop old, unused commits + Commit.objects.filter(tag__isnull=True).delete() + + +def get_tags() -> list[Tag]: + """Return a list of all tags visible to the application, from the cache or API.""" + if settings.STATIC_BUILD: # pragma: no cover + last_update = None + else: + last_update = ( + Tag.objects.values_list("last_updated", flat=True) + .order_by("last_updated").first() + ) + + if last_update is None or timezone.now() >= (last_update + TAG_CACHE_TTL): + # Stale or empty cache + if settings.STATIC_BUILD: # pragma: no cover + tags = get_tags_static() + else: + tags = fetch_tags() + record_tags(tags) + + return tags + + return list(Tag.objects.all()) + + +def get_tag(path: str, *, skip_sync: bool = False) -> Tag | list[Tag]: + """ + Return a tag based on the search location. + + If certain tag data is out of sync (for instance a commit date is missing), + an extra request will be made to sync the information. + + The tag name and group must match. If only one argument is provided in the path, + it's assumed to either be a group name, or a no-group tag name. + + If it's a group name, a list of tags which belong to it is returned. + """ + path = path.split("/") + if len(path) == 2: + group, name = path + else: + name = path[0] + group = None + + matches = [] + for tag in get_tags(): + if tag.name == name and tag.group == group: + if tag.last_commit is None and not skip_sync: + set_tag_commit(tag) + return tag + elif tag.group == name and group is None: # noqa: RET505 + matches.append(tag) + + if matches: + return matches + + raise Tag.DoesNotExist + + +def get_tag_category(tags: list[Tag] | None = None, *, collapse_groups: bool) -> dict[str, dict]: + """ + Generate context data for `tags`, or all tags if None. + + If `tags` is None, `get_tag` is used to populate the data. + If `collapse_groups` is True, tags with parent groups are not included in the list, + and instead the parent itself is included as a single entry with it's sub-tags + in the description. + """ + if not tags: + tags = get_tags() + + data = [] + groups = {} + + # Create all the metadata for the tags + for tag in tags: + if tag.group is None or not collapse_groups: + content = frontmatter.parse(tag.body)[1] + data.append({ + "title": tag.name, + "description": markdown.markdown(content, extensions=["pymdownx.superfences"]), + "icon": "fas fa-tag", + }) + else: + if tag.group not in groups: + groups[tag.group] = { + "title": tag.group, + "description": [tag.name], + "icon": "fas fa-tags", + } + else: + groups[tag.group]["description"].append(tag.name) + + # Flatten group description into a single string + for group in groups.values(): + # If the following string is updated, make sure to update it in the frontend JS as well + group["description"] = "Contains the following tags: " + ", ".join(group["description"]) + data.append(group) + + # Sort the tags, and return them in the proper format + return {tag["title"]: tag for tag in sorted(data, key=lambda tag: tag["title"].casefold())} + + +def get_category_pages(path: Path) -> dict[str, dict]: """Get all page names and their metadata at a category path.""" + # Special handling for tags + if path == Path(__file__).parent / "resources/tags": + return get_tag_category(collapse_groups=True) + pages = {} for item in path.glob("*.md"): @@ -39,7 +333,7 @@ def get_category_pages(path: Path) -> Dict[str, Dict]: return pages -def get_page(path: Path) -> Tuple[str, Dict]: +def get_page(path: Path) -> tuple[str, dict]: """Get one specific page.""" if not path.is_file(): raise Http404("Page not found.") diff --git a/pydis_site/apps/content/views/__init__.py b/pydis_site/apps/content/views/__init__.py index 70ea1c7a..a969b1dc 100644 --- a/pydis_site/apps/content/views/__init__.py +++ b/pydis_site/apps/content/views/__init__.py @@ -1,3 +1,4 @@ from .page_category import PageOrCategoryView +from .tags import TagView -__all__ = ["PageOrCategoryView"] +__all__ = ["PageOrCategoryView", "TagView"] diff --git a/pydis_site/apps/content/views/page_category.py b/pydis_site/apps/content/views/page_category.py index 5af77aff..1d9a9c39 100644 --- a/pydis_site/apps/content/views/page_category.py +++ b/pydis_site/apps/content/views/page_category.py @@ -1,18 +1,17 @@ -import typing as t 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 +from pydis_site.apps.content import models, 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", "")) @@ -25,7 +24,7 @@ class PageOrCategoryView(TemplateView): return super().dispatch(request, *args, **kwargs) - def get_template_names(self) -> t.List[str]: + def get_template_names(self) -> list[str]: """Checks if the view uses the page template or listing template.""" if self.page_path.is_file(): template_name = "content/page.html" @@ -36,7 +35,7 @@ class PageOrCategoryView(TemplateView): return [template_name] - def get_context_data(self, **kwargs) -> t.Dict[str, t.Any]: + def get_context_data(self, **kwargs) -> dict[str, any]: """Assign proper context variables based on what resource user requests.""" context = super().get_context_data(**kwargs) @@ -57,9 +56,7 @@ class PageOrCategoryView(TemplateView): entry_info["name"] = frontmatter.load(entry).metadata["title"] elif entry.is_dir(): entry_info["name"] = utils.get_category(entry)["title"] - else: # pragma: no cover - # TODO: Remove coverage.py pragma in Python 3.10 - # See: https://github.com/nedbat/coveragepy/issues/198 + else: continue context["subarticles"].append(entry_info) @@ -73,7 +70,7 @@ class PageOrCategoryView(TemplateView): return context @staticmethod - def _get_page_context(path: Path) -> t.Dict[str, t.Any]: + def _get_page_context(path: Path) -> dict[str, any]: page, metadata = utils.get_page(path) return { "page": page, @@ -84,7 +81,7 @@ class PageOrCategoryView(TemplateView): } @staticmethod - def _get_category_context(path: Path) -> t.Dict[str, t.Any]: + def _get_category_context(path: Path) -> dict[str, any]: category = utils.get_category(path) return { "categories": utils.get_categories(path), @@ -92,4 +89,7 @@ class PageOrCategoryView(TemplateView): "page_title": category["title"], "page_description": category["description"], "icon": category.get("icon"), + "app_name": "content:page_category", + "is_tag_listing": "/resources/tags" in path.as_posix(), + "tag_url": models.Tag.URL_BASE, } diff --git a/pydis_site/apps/content/views/tags.py b/pydis_site/apps/content/views/tags.py new file mode 100644 index 00000000..8d3e3321 --- /dev/null +++ b/pydis_site/apps/content/views/tags.py @@ -0,0 +1,123 @@ +import re + +import frontmatter +import markdown +from django.conf import settings +from django.http import Http404 +from django.urls import reverse +from django.views.generic import TemplateView + +from pydis_site.apps.content import utils +from pydis_site.apps.content.models import Tag + +# The following regex tries to parse a tag command +# It'll read up to two words seperated by spaces +# If the command does not include a group, the tag name will be in the `first` group +# If there's a second word after the command, or if there's a tag group, extra logic +# is necessary to determine whether it's a tag with a group, or a tag with text after it +COMMAND_REGEX = re.compile(r"`*!tags? (?P<first>[\w-]+)(?P<second> [\w-]+)?`*") + + +class TagView(TemplateView): + """Handles tag pages.""" + + tag: Tag | list[Tag] + is_group: bool + + def setup(self, *args, **kwargs) -> None: + """Look for a tag, and configure the view.""" + super().setup(*args, **kwargs) + + try: + self.tag = utils.get_tag(kwargs.get("location")) + self.is_group = isinstance(self.tag, list) + except Tag.DoesNotExist: + raise Http404 + + def get_template_names(self) -> list[str]: + """Either return the tag page template, or the listing.""" + if self.is_group: + template_name = "content/listing.html" + else: + template_name = "content/tag.html" + + return [template_name] + + def get_context_data(self, **kwargs) -> dict: + """Get the relevant context for this tag page or group.""" + context = super().get_context_data(**kwargs) + context["breadcrumb_items"] = [{ + "name": utils.get_category(settings.CONTENT_PAGES_PATH / location)["title"], + "path": location, + } for location in (".", "tags")] + + if self.is_group: + self._set_group_context(context, self.tag) + else: + self._set_tag_context(context, self.tag) + + return context + + @staticmethod + def _set_tag_context(context: dict[str, any], tag: Tag) -> None: + """Update the context with the information for a tag page.""" + context.update({ + "page_title": tag.name, + "tag": tag, + }) + + if tag.group: + # Add group names to the breadcrumbs + context["breadcrumb_items"].append({ + "name": tag.group, + "path": f"tags/{tag.group}", + }) + + # Clean up tag body + body = frontmatter.parse(tag.body) + content = body[1] + + # Check for tags which can be hyperlinked + def sub(match: re.Match) -> str: + first, second = match.groups() + location = first + text, extra = match.group(), "" + + if second is not None: + # Possibly a tag group + try: + new_location = f"{first}/{second.strip()}" + utils.get_tag(new_location, skip_sync=True) + location = new_location + except Tag.DoesNotExist: + # Not a group, remove the second argument from the link + extra = text[text.find(second):] + text = text[:text.find(second)] + + link = reverse("content:tag", kwargs={"location": location}) + return f"[{text}]({link}){extra}" + content = COMMAND_REGEX.sub(sub, content) + + # Add support for some embed elements + if embed := body[0].get("embed"): + context["page_title"] = embed["title"] + if image := embed.get("image"): + content = f"![{embed['title']}]({image['url']})\n\n" + content + + # Insert the content + context["page"] = markdown.markdown(content, extensions=["pymdownx.superfences"]) + + @staticmethod + def _set_group_context(context: dict[str, any], tags: list[Tag]) -> None: + """Update the context with the information for a group of tags.""" + group = tags[0].group + context.update({ + "categories": {}, + "pages": utils.get_tag_category(tags, collapse_groups=False), + "page_title": group, + "icon": "fab fa-tags", + "is_tag_listing": True, + "app_name": "content:tag", + "path": f"{group}/", + "tag_url": f"{tags[0].URL_BASE}/{group}" + }) diff --git a/pydis_site/apps/events/README.md b/pydis_site/apps/events/README.md new file mode 100644 index 00000000..f0d20510 --- /dev/null +++ b/pydis_site/apps/events/README.md @@ -0,0 +1,19 @@ +# The "events" app + +This application serves mostly static pages that showcase events we run on our +community. You most likely want to look at the [templates +directory](../../templates/events) for this app if you want to change anything. + +## Directory structure + +This app has a relatively minimal structure: + +- `migrations` is empty as we don't work with any models here. + +- `tests` contains a few tests to make sure that serving our events pages works. + +- `views` contains Django views that concern themselves with looking up the + matching Django template. + +The actual content lives in the [templates directory two layers +up](../../templates/events). diff --git a/pydis_site/apps/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/events/urls.py b/pydis_site/apps/events/urls.py index 7ea65a31..6121d264 100644 --- a/pydis_site/apps/events/urls.py +++ b/pydis_site/apps/events/urls.py @@ -8,7 +8,7 @@ from pydis_site.apps.events.views import IndexView, PageView app_name = "events" -def __get_all_files(root: Path, folder: typing.Optional[Path] = None) -> list[str]: +def __get_all_files(root: Path, folder: Path | None = None) -> list[str]: """Find all folders and HTML files recursively starting from `root`.""" if not folder: folder = root diff --git a/pydis_site/apps/events/views/page.py b/pydis_site/apps/events/views/page.py index 1622ad70..adf9e952 100644 --- a/pydis_site/apps/events/views/page.py +++ b/pydis_site/apps/events/views/page.py @@ -1,4 +1,3 @@ -from typing import List from django.conf import settings from django.http import Http404 @@ -8,7 +7,7 @@ from django.views.generic import TemplateView class PageView(TemplateView): """Handles event pages showing.""" - def get_template_names(self) -> List[str]: + def get_template_names(self) -> list[str]: """Get specific template names.""" path: str = self.kwargs['path'] page_path = settings.EVENTS_PAGES_PATH / path diff --git a/pydis_site/apps/home/README.md b/pydis_site/apps/home/README.md new file mode 100644 index 00000000..34c1e367 --- /dev/null +++ b/pydis_site/apps/home/README.md @@ -0,0 +1,35 @@ +# The "home" app + +This Django application takes care of serving the homepage of our website, that +is, the first page that you see when you open pythondiscord.com. It also +manages the timeline page showcasing the history of our community. + +## Directory structure + +- `migrations` is the standard Django migrations folder. As with [the API + app](../api/README.md), you usually won't need to edit this manually, use + `python manage.py makemigrations [-n short_description]` to create a new + migration here. + +- `templatetags` contains custom [template tags and + filters](https://docs.djangoproject.com/en/dev/howto/custom-template-tags/) + used in the home app. + +- `tests` contains unit tests that validate the home app works as expected. If + you're looking for guidance in writing tests, the [Django tutorial + introducing automated + testing](https://docs.djangoproject.com/en/dev/intro/tutorial05/) is a great + starting point. + +As for the Python modules residing directly in here: + +- `models.py` contains our Django model definitions for this app. As this app + is rather minimal, this is kept as a single module - more models would be + split up into a subfolder as in the other apps. + +- `urls.py` configures Django's [URL + dispatcher](https://docs.djangoproject.com/en/dev/topics/http/urls/) for our + home endpoints. + +- `views.py` contains our Django views. You can see where they are linked in the + URL dispatcher. diff --git a/pydis_site/apps/home/models/repository_metadata.py b/pydis_site/apps/home/models.py index 00a83cd7..00a83cd7 100644 --- a/pydis_site/apps/home/models/repository_metadata.py +++ b/pydis_site/apps/home/models.py diff --git a/pydis_site/apps/home/models/__init__.py b/pydis_site/apps/home/models/__init__.py deleted file mode 100644 index 6c68df9c..00000000 --- a/pydis_site/apps/home/models/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .repository_metadata import RepositoryMetadata - -__all__ = ["RepositoryMetadata"] diff --git a/pydis_site/apps/home/tests/test_repodata_helpers.py b/pydis_site/apps/home/tests/test_repodata_helpers.py index 5634bc9b..acf4a817 100644 --- a/pydis_site/apps/home/tests/test_repodata_helpers.py +++ b/pydis_site/apps/home/tests/test_repodata_helpers.py @@ -22,7 +22,7 @@ def mocked_requests_get(*args, **kwargs) -> "MockResponse": # noqa: F821 if args[0] == HomeView.github_api: json_path = Path(__file__).resolve().parent / "mock_github_api_response.json" - with open(json_path, 'r') as json_file: + with open(json_path) as json_file: mock_data = json.load(json_file) return MockResponse(mock_data, 200) @@ -36,13 +36,13 @@ 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() self.assertIsInstance(metadata[0], RepositoryMetadata) - self.assertEquals(len(metadata), len(self.home_view.repos)) + self.assertEqual(len(metadata), len(self.home_view.repos)) def test_returns_cached_metadata(self): """Test if the _get_repo_data helper returns cached data when available.""" @@ -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,18 +75,18 @@ 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() repo = self.home_view.repos[0] self.assertIsInstance(api_data, dict) - self.assertEquals(len(api_data), len(self.home_view.repos)) + self.assertEqual(len(api_data), len(self.home_view.repos)) 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,13 +117,16 @@ 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() - self.assertEquals(len(metadata), 0) + # Capture logs and ensure the problematic response is logged + with self.assertLogs(): + metadata = self.home_view._get_repo_data() + + self.assertEqual(len(metadata), 0) def test_cleans_up_stale_metadata(self): """Tests that we clean up stale metadata when we start the HomeView.""" diff --git a/pydis_site/apps/home/tests/test_views.py b/pydis_site/apps/home/tests/test_views.py index b1215df4..379b984e 100644 --- a/pydis_site/apps/home/tests/test_views.py +++ b/pydis_site/apps/home/tests/test_views.py @@ -1,3 +1,5 @@ +from unittest import mock + from django.test import TestCase from django.urls import reverse @@ -6,5 +8,6 @@ class TestIndexReturns200(TestCase): def test_index_returns_200(self): """Check that the index page returns a HTTP 200 response.""" url = reverse('home:home') - resp = self.client.get(url) + with mock.patch("pydis_site.apps.home.views.HomeView._get_api_data", autospec=True): + resp = self.client.get(url) self.assertEqual(resp.status_code, 200) diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views.py index e28a3a00..bfa9e02d 100644 --- a/pydis_site/apps/home/views/home.py +++ b/pydis_site/apps/home/views.py @@ -1,7 +1,6 @@ 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 +9,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__) @@ -33,9 +31,7 @@ class HomeView(View): def __init__(self): """Clean up stale RepositoryMetadata.""" - self._static_build = settings.env("STATIC_BUILD") - - if not self._static_build: + if not settings.STATIC_BUILD: RepositoryMetadata.objects.exclude(repo_name__in=self.repos).delete() # If no token is defined (for example in local development), then @@ -43,12 +39,12 @@ 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 = {} - def _get_api_data(self) -> Dict[str, Dict[str, str]]: + def _get_api_data(self) -> dict[str, dict[str, str]]: """ Call the GitHub API and get information about our repos. @@ -57,12 +53,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 @@ -92,10 +88,10 @@ class HomeView(View): return repo_dict - def _get_repo_data(self) -> List[RepositoryMetadata]: + def _get_repo_data(self) -> list[RepositoryMetadata]: """Build a list of RepositoryMetadata objects that we can use to populate the front page.""" # First off, load the timestamp of the least recently updated entry. - if self._static_build: + if settings.STATIC_BUILD: last_update = None else: last_update = ( @@ -122,10 +118,9 @@ class HomeView(View): for api_data in api_repositories.values() ] - if settings.env("STATIC_BUILD"): + if settings.STATIC_BUILD: return data - else: - return RepositoryMetadata.objects.bulk_create(data) + return RepositoryMetadata.objects.bulk_create(data) # If the data is stale, we should refresh it. if (timezone.now() - last_update).seconds > self.repository_cache_ttl: @@ -152,8 +147,7 @@ class HomeView(View): return database_repositories # Otherwise, if the data is fresher than 2 minutes old, we should just return it. - else: - return RepositoryMetadata.objects.all() + return RepositoryMetadata.objects.all() def get(self, request: WSGIRequest) -> HttpResponse: """Collect repo data and render the homepage view.""" diff --git a/pydis_site/apps/home/views/__init__.py b/pydis_site/apps/home/views/__init__.py deleted file mode 100644 index 28cc4d65..00000000 --- a/pydis_site/apps/home/views/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .home import HomeView, timeline - -__all__ = ["HomeView", "timeline"] diff --git a/pydis_site/apps/redirect/README.md b/pydis_site/apps/redirect/README.md new file mode 100644 index 00000000..0d3c1e33 --- /dev/null +++ b/pydis_site/apps/redirect/README.md @@ -0,0 +1,13 @@ +# The "redirect" app + +This Django application manages redirects on our website. The main magic +happens in `urls.py`, which transforms our redirects as configured in +`redirects.yaml` into Django URL routing rules. `tests.py` on the other hand +simply checks that all redirects configured in `redirects.yaml` work as +expected. + +As suggested by the comment in `redirects.yaml`, this app is mainly here for +backwards compatibility for our old dewikification project. It is unlikely you +need to edit it directly. If you did find a reason to perform changes here, +please [open an +issue](https://github.com/python-discord/site/issues/new/choose)! diff --git a/pydis_site/apps/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 9b64011b..4a48ba0c 100644 --- a/pydis_site/apps/redirect/redirects.yaml +++ b/pydis_site/apps/redirect/redirects.yaml @@ -83,6 +83,11 @@ good_questions_redirect_alt: redirect_arguments: ["guides/pydis-guides/asking-good-questions"] # Resources +resources_old_communities_redirect: + original_path: pages/resources/communities/ + redirect_route: "resources:index" + redirect_arguments: ["community"] + resources_index_redirect: original_path: pages/resources/ redirect_route: "resources:index" diff --git a/pydis_site/apps/redirect/urls.py b/pydis_site/apps/redirect/urls.py index f7ddf45b..ca8b379d 100644 --- a/pydis_site/apps/redirect/urls.py +++ b/pydis_site/apps/redirect/urls.py @@ -4,12 +4,8 @@ import re import yaml from django import conf from django.urls import URLPattern, path -from django_distill import distill_path -from pydis_site import settings -from pydis_site.apps.content import urls as pages_urls from pydis_site.apps.redirect.views import CustomRedirectView -from pydis_site.apps.resources import urls as resources_urls app_name = "redirect" @@ -30,74 +26,16 @@ class Redirect: def map_redirect(name: str, data: Redirect) -> list[URLPattern]: - """Return a pattern using the Redirects app, or a static HTML redirect for static builds.""" - if not settings.env("STATIC_BUILD"): - # Normal dynamic redirect - return [path( - data.original_path, - CustomRedirectView.as_view( - pattern_name=data.redirect_route, - static_args=tuple(data.redirect_arguments), - prefix_redirect=data.prefix_redirect - ), - name=name - )] - - # Create static HTML redirects for static builds - new_app_name = data.redirect_route.split(":")[0] - - if __PARAMETER_REGEX.search(data.original_path): - # Redirects for paths which accept parameters - # We generate an HTML redirect file for all possible entries - paths = [] - - class RedirectFunc: - def __init__(self, new_url: str, _name: str): - self.result = REDIRECT_TEMPLATE.format(url=new_url) - self.__qualname__ = _name - - def __call__(self, *args, **kwargs): - return self.result - - if new_app_name == resources_urls.app_name: - items = resources_urls.get_all_resources() - elif new_app_name == pages_urls.app_name: - items = pages_urls.get_all_pages() - else: - raise ValueError(f"Unknown app in redirect: {new_app_name}") - - for item in items: - entry = list(item.values())[0] - - # Replace dynamic redirect with concrete path - concrete_path = __PARAMETER_REGEX.sub(entry, data.original_path) - new_redirect = f"/{new_app_name}/{entry}" - pattern_name = f"{name}_{entry}" - - paths.append(distill_path( - concrete_path, - RedirectFunc(new_redirect, pattern_name), - name=pattern_name - )) - - return paths - - else: - redirect_path_name = "pages" if new_app_name == "content" else new_app_name - if len(data.redirect_arguments) > 0: - redirect_arg = data.redirect_arguments[0] - else: - redirect_arg = "resources/" - new_redirect = f"/{redirect_path_name}/{redirect_arg}" - - if new_redirect == "/resources/resources/": - new_redirect = "/resources/" - - return [distill_path( - data.original_path, - lambda *args: REDIRECT_TEMPLATE.format(url=new_redirect), - name=name, - )] + """Return a pattern using the Redirects app.""" + return [path( + data.original_path, + CustomRedirectView.as_view( + pattern_name=data.redirect_route, + static_args=tuple(data.redirect_arguments), + prefix_redirect=data.prefix_redirect + ), + name=name + )] urlpatterns = [] diff --git a/pydis_site/apps/redirect/views.py b/pydis_site/apps/redirect/views.py index 21180cdf..374daf2b 100644 --- a/pydis_site/apps/redirect/views.py +++ b/pydis_site/apps/redirect/views.py @@ -1,4 +1,3 @@ -import typing as t from django.views.generic import RedirectView @@ -15,7 +14,7 @@ class CustomRedirectView(RedirectView): """Overwrites original as_view to add static args.""" return super().as_view(**initkwargs) - def get_redirect_url(self, *args, **kwargs) -> t.Optional[str]: + def get_redirect_url(self, *args, **kwargs) -> str | None: """Extends default behaviour to use static args.""" args = self.static_args + args + tuple(kwargs.values()) if self.prefix_redirect: diff --git a/pydis_site/apps/resources/README.md b/pydis_site/apps/resources/README.md new file mode 100644 index 00000000..6f41319a --- /dev/null +++ b/pydis_site/apps/resources/README.md @@ -0,0 +1,29 @@ +# The "resources" app + +This Django application powering the resources list [on our +website](https://www.pythondiscord.com/resources/). + +## Directory structure + +The main point of interest here lies in the `resources` directory: every +`.yaml` file in here represents a resource that is listed on our website. If +you are looking for the place to suggest new resources, said directory is the +place to create a new YAML file. In regards to the required keys and our +values, it's best to check the other files we have for a reference. + +The app has a single view in `views.py` that takes care of reading the `.yaml` +file. This is a standard Django view, mounted in `urls.py` as usual. + +Similar to the [home app](../home), the `templatetags` directory contains custom +[template tags and +filters](https://docs.djangoproject.com/en/dev/howto/custom-template-tags/) used +here. + +The `tests` directory validates that our redirects and helper functions work as +expected. If you made changes to the app and are looking for guidance on adding +new tests, the [Django tutorial introducing automated +testing](https://docs.djangoproject.com/en/dev/intro/tutorial05/) is a good +place to start. + +This application does not use the database and as such does not have models nor +migrations. diff --git a/pydis_site/apps/resources/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/adafruit.yaml b/pydis_site/apps/resources/resources/adafruit.yaml index f9466bd8..c687f507 100644 --- a/pydis_site/apps/resources/resources/adafruit.yaml +++ b/pydis_site/apps/resources/resources/adafruit.yaml @@ -1,3 +1,4 @@ +name: Adafruit description: Adafruit is an open-source electronics manufacturer that makes all the components you need to start your own Python-powered hardware projects. Their official community host regular show-and-tells, diff --git a/pydis_site/apps/resources/resources/atom.yaml b/pydis_site/apps/resources/resources/atom.yaml deleted file mode 100644 index 26e125b1..00000000 --- a/pydis_site/apps/resources/resources/atom.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: A free Electron-based editor, a "hackable text editor for the 21st century", maintained - by the GitHub team. -name: Atom -title_url: https://atom.io/ -tags: - topics: - - general - payment_tiers: - - free - difficulty: - - beginner - - intermediate - type: - - tool diff --git a/pydis_site/apps/resources/resources/automate_the_boring_stuff_course.yaml b/pydis_site/apps/resources/resources/automate_the_boring_stuff_course.yaml index 133033f7..4632f5bd 100644 --- a/pydis_site/apps/resources/resources/automate_the_boring_stuff_course.yaml +++ b/pydis_site/apps/resources/resources/automate_the_boring_stuff_course.yaml @@ -1,7 +1,6 @@ description: The interactive course version of Al Sweigart's excellent book for beginners, taught by the author himself. - This link has a discounted version of the course which will always cost 10 dollars. Thanks, Al! -name: Automate the Boring Stuff with Python -title_url: https://www.udemy.com/automate/?couponCode=FOR_LIKE_10_BUCKS +name: Automate the Boring Stuff with Python Udemy Course +title_url: https://www.udemy.com/automate/ tags: topics: - general diff --git a/pydis_site/apps/resources/resources/corey_schafer.yaml b/pydis_site/apps/resources/resources/corey_schafer.yaml index f5af2cab..d66ea004 100644 --- a/pydis_site/apps/resources/resources/corey_schafer.yaml +++ b/pydis_site/apps/resources/resources/corey_schafer.yaml @@ -1,3 +1,4 @@ +name: Corey Schafer description: 'Corey has a number of exceptionally high quality tutorial series on everything from Python basics to Django and Flask: <ul> diff --git a/pydis_site/apps/resources/resources/exercism.yaml b/pydis_site/apps/resources/resources/exercism.yaml index b8f53d72..c623db2d 100644 --- a/pydis_site/apps/resources/resources/exercism.yaml +++ b/pydis_site/apps/resources/resources/exercism.yaml @@ -2,8 +2,8 @@ description: Level up your programming skills with more than 2600 exercises acro 47 programming languages, Python included. The website provides a mentored mode, where you can get your code reviewed for each solution you submit. The mentors will give you insightful advice to make you a better programmer. -name: exercism.io -title_url: https://exercism.io/ +name: Exercism +title_url: https://exercism.org/ urls: - icon: branding/github url: https://github.com/exercism/python diff --git a/pydis_site/apps/resources/resources/getting_started_with_kivy.yaml b/pydis_site/apps/resources/resources/getting_started_with_kivy.yaml index 06eb2c14..0a020d6b 100644 --- a/pydis_site/apps/resources/resources/getting_started_with_kivy.yaml +++ b/pydis_site/apps/resources/resources/getting_started_with_kivy.yaml @@ -1,7 +1,7 @@ description: A big list of excellent resources for getting started making Kivy applications. name: Getting Started with Kivy title_url: https://blog.kivy.org/2019/12/getting-started-with-kivy/ -icon_image: https://raw.githubusercontent.com/kivy/kivy-website/master/logos/kivy-logo-black-256.png +icon_image: https://raw.githubusercontent.com/kivy/kivy-website/master/content/logos/kivy-logo-black-256.png tags: topics: - user interface diff --git a/pydis_site/apps/resources/resources/kivy.yaml b/pydis_site/apps/resources/resources/kivy.yaml index 47ff07ad..f49c33fb 100644 --- a/pydis_site/apps/resources/resources/kivy.yaml +++ b/pydis_site/apps/resources/resources/kivy.yaml @@ -1,14 +1,12 @@ +name: Kivy description: The Kivy project, through the Kivy framework and its sister projects, aims to provide all the tools to create desktop and mobile applications in Python. Allowing rapid development of multitouch applications with custom and exciting user interfaces. -icon_image: https://raw.githubusercontent.com/kivy/kivy-website/master/logos/kivy-logo-black-256.png +icon_image: https://raw.githubusercontent.com/kivy/kivy-website/master/content/logos/kivy-logo-black-256.png icon_size: 50 title_image: https://i.imgur.com/EVP3jZR.png -title_url: https://discord.gg/djPtTRJ +title_url: https://kivy.org/ urls: - - icon: solid/external-link-alt - url: https://kivy.org/ - color: teal - icon: branding/discord url: https://discord.gg/djPtTRJ color: blurple diff --git a/pydis_site/apps/resources/resources/microsoft.yaml b/pydis_site/apps/resources/resources/microsoft.yaml index e1d62955..290283cc 100644 --- a/pydis_site/apps/resources/resources/microsoft.yaml +++ b/pydis_site/apps/resources/resources/microsoft.yaml @@ -1,3 +1,4 @@ +name: Microsoft Python description: Microsoft Python is a Discord server for discussing all things relating to using Python with Microsoft products, they have channels for Azure, VS Code, IoT, Data Science and much more! title_image: https://1000logos.net/wp-content/uploads/2017/04/Microsoft-Logo.png diff --git a/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml b/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml index c4ad1e1b..26e88cb9 100644 --- a/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml +++ b/pydis_site/apps/resources/resources/neural_networks_from_scratch_in_python.yaml @@ -2,7 +2,7 @@ description: '"Neural Networks From Scratch" is a book intended to teach you how without any libraries, so you can better understand deep learning and how all of the elements work. This is so you can go out and do new/novel things with deep learning as well as to become more successful with even more basic models. This book is to accompany the usual free tutorial videos and sample code from youtube.com/sentdex.' -name: Neural Networks from Scratch in Python +name: Neural Networks from Scratch title_url: https://nnfs.io/ urls: - icon: branding/goodreads diff --git a/pydis_site/apps/resources/resources/pallets.yaml b/pydis_site/apps/resources/resources/pallets.yaml index 0da2a625..a330b756 100644 --- a/pydis_site/apps/resources/resources/pallets.yaml +++ b/pydis_site/apps/resources/resources/pallets.yaml @@ -1,3 +1,4 @@ +name: Pallets Projects description: The Pallets Projects develop Python libraries such as the Flask web framework, the Jinja templating library, and the Click command line toolkit. Join to discuss and get help from the Pallets community. diff --git a/pydis_site/apps/resources/resources/panda3d.yaml b/pydis_site/apps/resources/resources/panda3d.yaml index 2040450d..51861474 100644 --- a/pydis_site/apps/resources/resources/panda3d.yaml +++ b/pydis_site/apps/resources/resources/panda3d.yaml @@ -1,12 +1,10 @@ +name: Panda3D description: Panda3D is a Python-focused 3-D framework for rapid development of games, visualizations, and simulations, written in C++ with an emphasis on performance and flexibility. title_image: https://www.panda3d.org/wp-content/uploads/2019/01/panda3d_logo.png -title_url: https://discord.gg/9XsucTT +title_url: https://www.panda3d.org/ position: 9 urls: - - icon: solid/external-link-alt - url: https://www.panda3d.org/ - color: teal - icon: branding/discord url: https://discord.gg/9XsucTT color: blurple diff --git a/pydis_site/apps/resources/resources/people_postgres_data.yaml b/pydis_site/apps/resources/resources/people_postgres_data.yaml index 46db7095..212eed89 100644 --- a/pydis_site/apps/resources/resources/people_postgres_data.yaml +++ b/pydis_site/apps/resources/resources/people_postgres_data.yaml @@ -1,14 +1,12 @@ +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. They take a holistic approach to their community inviting not only technical topics but Professional Development and Life in general including movies, games, books and travel. title_image: https://media.discordapp.net/attachments/748954447857844318/750519488268730377/people_postgres_data.png -title_url: https://discord.gg/Ujw8m8v +title_url: https://postgresconf.org/ urls: - - icon: solid/external-link-alt - url: https://postgresconf.org/ - color: teal - icon: branding/discord url: https://discord.gg/Ujw8m8v color: bluple diff --git a/pydis_site/apps/resources/resources/pycharm.yaml b/pydis_site/apps/resources/resources/pycharm.yaml index 574158bc..e8c787e6 100644 --- a/pydis_site/apps/resources/resources/pycharm.yaml +++ b/pydis_site/apps/resources/resources/pycharm.yaml @@ -1,6 +1,7 @@ description: The very best Python IDE, with a wealth of advanced features and convenience functions. name: PyCharm +title_image: https://resources.jetbrains.com/storage/products/pycharm/img/meta/pycharm_logo_300x300.png title_url: https://www.jetbrains.com/pycharm/ tags: topics: diff --git a/pydis_site/apps/resources/resources/pyglet.yaml b/pydis_site/apps/resources/resources/pyglet.yaml index a47c7e62..bdfb84cf 100644 --- a/pydis_site/apps/resources/resources/pyglet.yaml +++ b/pydis_site/apps/resources/resources/pyglet.yaml @@ -1,3 +1,4 @@ +name: Pyglet description: Pyglet is a powerful, yet easy to use Python library for developing games and other visually-rich applications on Windows, Mac OS X and Linux. It supports windowing, user interface event handling, Joysticks, OpenGL graphics, diff --git a/pydis_site/apps/resources/resources/python_discord_videos.yaml b/pydis_site/apps/resources/resources/python_discord_videos.yaml index 15a04097..012ec8ea 100644 --- a/pydis_site/apps/resources/resources/python_discord_videos.yaml +++ b/pydis_site/apps/resources/resources/python_discord_videos.yaml @@ -1,3 +1,4 @@ +name: Python Discord YouTube Channel description: It's our YouTube channel! We are slowly gathering content here directly related to Python, our community and the events we host. Come check us out! title_image: https://raw.githubusercontent.com/python-discord/branding/master/logos/logo_banner/logo_site_banner_dark_512.png diff --git a/pydis_site/apps/resources/resources/python_graph_gallery.yaml b/pydis_site/apps/resources/resources/python_graph_gallery.yaml new file mode 100644 index 00000000..b8aaeb4d --- /dev/null +++ b/pydis_site/apps/resources/resources/python_graph_gallery.yaml @@ -0,0 +1,17 @@ +description: A collection of hundreds of charts made with Python with their associated reproducible code. +name: Python Graph Gallery +title_url: https://www.python-graph-gallery.com/ +urls: + - icon: branding/github + url: https://github.com/holtzy/The-Python-Graph-Gallery + color: black +tags: + topics: + - data science + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tutorial diff --git a/pydis_site/apps/resources/resources/python_morsels.yaml b/pydis_site/apps/resources/resources/python_morsels.yaml index bbc8133b..4cdff36b 100644 --- a/pydis_site/apps/resources/resources/python_morsels.yaml +++ b/pydis_site/apps/resources/resources/python_morsels.yaml @@ -17,3 +17,4 @@ tags: - intermediate type: - interactive + - video diff --git a/pydis_site/apps/resources/resources/python_org.yaml b/pydis_site/apps/resources/resources/python_org.yaml new file mode 100644 index 00000000..ece954dd --- /dev/null +++ b/pydis_site/apps/resources/resources/python_org.yaml @@ -0,0 +1,14 @@ +name: The Python Tutorial +description: The official Python tutorial by Python.org +title_image: https://www.python.org/static/community_logos/python-logo-master-v3-TM.png +title_url: https://docs.python.org/3/tutorial/ +tags: + topics: + - general + payment_tiers: + - free + difficulty: + - beginner + - intermediate + type: + - tutorial diff --git a/pydis_site/apps/resources/resources/real_python.yaml b/pydis_site/apps/resources/resources/real_python.yaml index 2ddada03..93953004 100644 --- a/pydis_site/apps/resources/resources/real_python.yaml +++ b/pydis_site/apps/resources/resources/real_python.yaml @@ -1,3 +1,4 @@ +name: Real Python description: Dan Bader's treasure trove of quizzes, tutorials and interactive content for learning Python. An absolute goldmine. title_image: https://i.imgur.com/WDqhZ36.png diff --git a/pydis_site/apps/resources/resources/sentdex.yaml b/pydis_site/apps/resources/resources/sentdex.yaml index 4f4712ac..7cb0a8a4 100644 --- a/pydis_site/apps/resources/resources/sentdex.yaml +++ b/pydis_site/apps/resources/resources/sentdex.yaml @@ -1,3 +1,4 @@ +name: Sentdex description: 'An enormous amount of Python content for all skill levels from the most popular Python YouTuber on the web. <ul> diff --git a/pydis_site/apps/resources/resources/socratica.yaml b/pydis_site/apps/resources/resources/socratica.yaml index 43d033c0..45150b33 100644 --- a/pydis_site/apps/resources/resources/socratica.yaml +++ b/pydis_site/apps/resources/resources/socratica.yaml @@ -1,3 +1,4 @@ +name: Socratica description: 'Socratica is a small studio focused on producing high quality STEM-related educational content, including a series about Python. Their videos star actress Ulka Simone Mohanty, who plays an android-like instructor explaining fundamental concepts in a concise and entertaining way.' diff --git a/pydis_site/apps/resources/resources/sololearn.yaml b/pydis_site/apps/resources/resources/sololearn.yaml index 998f5368..1c90a597 100644 --- a/pydis_site/apps/resources/resources/sololearn.yaml +++ b/pydis_site/apps/resources/resources/sololearn.yaml @@ -3,7 +3,7 @@ description: SoloLearn's Python 3 course serves as a simple and convenient intro you can pick it up and put it down between your busier aspects of life thanks to both PC and mobile apps being available to use. name: SoloLearn -title_url: https://www.sololearn.com/Course/Python/ +title_url: https://www.sololearn.com/learn/courses/python-introduction tags: topics: - general 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/two_scoops_of_django.yaml b/pydis_site/apps/resources/resources/two_scoops_of_django.yaml index 96eafd28..f372d35d 100644 --- a/pydis_site/apps/resources/resources/two_scoops_of_django.yaml +++ b/pydis_site/apps/resources/resources/two_scoops_of_django.yaml @@ -1,7 +1,7 @@ description: Tips, tricks, and best practices for your Django project. A highly recommended resource for Django web developers. name: Two Scoops of Django -title_url: https://www.feldroy.com/collections/everything/products/two-scoops-of-django-3-x +title_url: https://www.feldroy.com/books/two-scoops-of-django-3-x urls: - icon: branding/goodreads url: https://www.goodreads.com/book/show/55822151-two-scoops-of-django-3-x diff --git a/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml b/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml index 32476dab..482cdf91 100644 --- a/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml +++ b/pydis_site/apps/resources/resources/vcokltfre_discord_bot_tutorial.yaml @@ -1,8 +1,8 @@ -description: This tutorial, written by Python Discord staff member vcokltfre, +description: This tutorial, written by vcokltfre, will walk you through all the aspects of creating your own Discord bot, - starting from from creating the bot user itself. + starting from creating the bot user itself. name: vcokltfre's Discord Bot Tutorial -title_url: https://tutorial.vcokltfre.dev/ +title_url: https://tutorial.vco.sh/ tags: topics: - discord bots diff --git a/pydis_site/apps/resources/templatetags/get_category_icon.py b/pydis_site/apps/resources/templatetags/get_category_icon.py index 71f1393f..30bc4eaa 100644 --- a/pydis_site/apps/resources/templatetags/get_category_icon.py +++ b/pydis_site/apps/resources/templatetags/get_category_icon.py @@ -21,6 +21,7 @@ _ICONS = { "Paid": "fa-dollar-sign", "Podcast": "fa-microphone-alt", "Project Ideas": "fa-lightbulb-o", + "Security": "fa-solid fa-lock", "Software Design": "fa-paint-brush", "Subscription": "fa-credit-card", "Testing": "fa-vial", diff --git a/pydis_site/apps/resources/tests/test_resource_data.py b/pydis_site/apps/resources/tests/test_resource_data.py new file mode 100644 index 00000000..3a96e8b9 --- /dev/null +++ b/pydis_site/apps/resources/tests/test_resource_data.py @@ -0,0 +1,25 @@ +import yaml +from django.test import TestCase + +from pydis_site.apps.resources.views import RESOURCES_PATH + + +class TestResourceData(TestCase): + """Test data validity of resources.""" + + def test_no_duplicate_links(self): + """Test that there are no duplicate links in each resource.""" + for path in RESOURCES_PATH.rglob('*.yaml'): + with self.subTest(resource=path.stem): + content = yaml.safe_load(path.read_text()) + url_links = tuple(item['url'] for item in content.get('urls', ())) + if 'title_url' in content: + all_links = url_links + (content['title_url'],) + else: + all_links = url_links + + self.assertCountEqual( + all_links, + set(all_links), + msg="One or more links are duplicated on the resource", + ) diff --git a/pydis_site/apps/resources/urls.py b/pydis_site/apps/resources/urls.py index ed24dc99..cb33a9d7 100644 --- a/pydis_site/apps/resources/urls.py +++ b/pydis_site/apps/resources/urls.py @@ -1,9 +1,11 @@ from django_distill import distill_path -from pydis_site.apps.resources import views +from pydis_site.apps.resources.views import ResourceView app_name = "resources" urlpatterns = [ - distill_path("", views.resources.ResourceView.as_view(), name="index"), - distill_path("<resource_type>/", views.resources.ResourceView.as_view(), name="index"), + # Using `distill_path` instead of `path` allows this to be available + # in static preview builds. + distill_path("", ResourceView.as_view(), name="index"), + distill_path("<resource_type>/", ResourceView.as_view(), name="index"), ] diff --git a/pydis_site/apps/resources/views/resources.py b/pydis_site/apps/resources/views.py index 2375f722..a2cd8d0c 100644 --- a/pydis_site/apps/resources/views/resources.py +++ b/pydis_site/apps/resources/views.py @@ -1,5 +1,4 @@ import json -import typing as t from pathlib import Path import yaml @@ -22,7 +21,7 @@ class ResourceView(View): """Sort a tuple by its key alphabetically, disregarding 'the' as a prefix.""" name, resource = tuple_ name = name.casefold() - if name.startswith("the ") or name.startswith("the_"): + if name.startswith(("the ", "the_")): return name[4:] return name @@ -48,7 +47,7 @@ class ResourceView(View): } for resource_name, resource in self.resources.items(): css_classes = [] - for tag_type in resource_tags.keys(): + for tag_type in resource_tags: # Store the tags into `resource_tags` tags = resource.get("tags", {}).get(tag_type, []) for tag in tags: @@ -102,7 +101,7 @@ class ResourceView(View): "difficulty": [to_kebabcase(tier) for tier in self.filters["Difficulty"]["filters"]], } - def get(self, request: WSGIRequest, resource_type: t.Optional[str] = None) -> HttpResponse: + def get(self, request: WSGIRequest, resource_type: str | None = None) -> HttpResponse: """List out all the resources, and any filtering options from the URL.""" # Add type filtering if the request is made to somewhere like /resources/video. # We also convert all spaces to dashes, so they'll correspond with the filters. diff --git a/pydis_site/apps/resources/views/__init__.py b/pydis_site/apps/resources/views/__init__.py deleted file mode 100644 index 986f3e10..00000000 --- a/pydis_site/apps/resources/views/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .resources import ResourceView - -__all__ = ["ResourceView"] diff --git a/pydis_site/apps/staff/README.md b/pydis_site/apps/staff/README.md new file mode 100644 index 00000000..6707bf26 --- /dev/null +++ b/pydis_site/apps/staff/README.md @@ -0,0 +1,19 @@ +# The "staff" app + +This Django application hosts any staff-internal tooling, which, at time of +writing, is only an endpoint to view logs uploaded by the Python bot. + +This app mainly interacts with a single model from the `api` app, and has no +models on its own. The following files and directories are of interest: + +- [`templatetags`](./templatetags) contains custom template tags that help with + formatting the HTML templates of this app (these can be found in the template + root direcetory). + +- [`tests`](./tests) contains standard Django unit tests that validate both the + template tags and functionality of the log viewer itself. + +- [`urls.py`](./urls.py) contains the regular Django URL routing logic. + +- [`views.py`](./views.py) contains standard Django views. In our case, the + main work happens in the template, so this is relatively straightforward. diff --git a/pydis_site/apps/staff/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..c6638a3b 100644 --- a/pydis_site/apps/staff/templatetags/deletedmessage_filters.py +++ b/pydis_site/apps/staff/templatetags/deletedmessage_filters.py @@ -6,13 +6,16 @@ register = template.Library() @register.filter -def hex_colour(color: int) -> str: +def hex_colour(colour: 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" @@ -24,5 +27,5 @@ def footer_datetime(timestamp: str) -> datetime: @register.filter def visible_newlines(text: str) -> str: - """Takes an embed timestamp and returns a timezone-aware datetime object.""" + """Visualizes newlines in text by replacing them with a grey-ish `↵`.""" return text.replace("\n", " <span class='has-text-grey'>↵</span><br>") diff --git a/pydis_site/apps/staff/tests/test_deletedmessage_filters.py b/pydis_site/apps/staff/tests/test_deletedmessage_filters.py index 31215784..5e49f103 100644 --- a/pydis_site/apps/staff/tests/test_deletedmessage_filters.py +++ b/pydis_site/apps/staff/tests/test_deletedmessage_filters.py @@ -3,7 +3,7 @@ import enum from django.test import TestCase from django.utils import timezone -from ..templatetags import deletedmessage_filters +from pydis_site.apps.staff.templatetags import deletedmessage_filters class Colour(enum.IntEnum): diff --git a/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/apps/staff/urls.py b/pydis_site/apps/staff/urls.py index ca8d1a0f..0565592b 100644 --- a/pydis_site/apps/staff/urls.py +++ b/pydis_site/apps/staff/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .viewsets import LogView +from .views import LogView app_name = 'staff' urlpatterns = [ diff --git a/pydis_site/apps/staff/viewsets/logs.py b/pydis_site/apps/staff/views.py index 22dede95..22dede95 100644 --- a/pydis_site/apps/staff/viewsets/logs.py +++ b/pydis_site/apps/staff/views.py diff --git a/pydis_site/apps/staff/viewsets/__init__.py b/pydis_site/apps/staff/viewsets/__init__.py deleted file mode 100644 index 6b10eb83..00000000 --- a/pydis_site/apps/staff/viewsets/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .logs import LogView - -__all__ = ["LogView"] diff --git a/pydis_site/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 c8549d6a..35854454 100644 --- a/pydis_site/context_processors.py +++ b/pydis_site/context_processors.py @@ -1,8 +1,7 @@ +from django.conf import settings from django.http import HttpRequest -from pydis_site.constants import GIT_SHA - def git_sha_processor(_: HttpRequest) -> 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 d4e5a4a8..b5677029 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -10,31 +10,56 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/2.1/ref/settings/ """ +import logging import os import secrets import sys +import warnings from pathlib import Path from socket import gethostbyname, gethostname import environ import sentry_sdk +from sentry_sdk.integrations.logging import LoggingIntegration 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), + GITHUB_APP_ID=(str, None), + GITHUB_APP_KEY=(str, None), ) -sentry_sdk.init( - dsn=env('SITE_DSN'), - integrations=[DjangoIntegration()], - send_default_pii=True, - release=f"site@{GIT_SHA}" -) +GIT_SHA = env("GIT_SHA") +GITHUB_API = "https://api.github.com" +GITHUB_TOKEN = env("GITHUB_TOKEN") +GITHUB_APP_ID = env("GITHUB_APP_ID") +GITHUB_APP_KEY = env("GITHUB_APP_KEY") +GITHUB_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +"""The datetime string format GitHub uses.""" + +STATIC_BUILD: bool = env("STATIC_BUILD") + +if GITHUB_APP_KEY and (key_file := Path(GITHUB_APP_KEY)).is_file(): + # Allow the OAuth key to be loaded from a file + GITHUB_APP_KEY = key_file.read_text(encoding="utf-8") + +if not STATIC_BUILD: + sentry_sdk.init( + dsn=env('SITE_DSN'), + integrations=[DjangoIntegration(), LoggingIntegration(level=logging.DEBUG, event_level=logging.ERROR)], + send_default_pii=True, + release=f"site@{GIT_SHA}", + profiles_sample_rate=1.0, + enable_tracing=True, + enable_db_query_source=True, + db_query_source_threshold_ms=100, # Queries slower that 100ms will include the source in the event + ) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -48,10 +73,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', @@ -69,7 +110,7 @@ else: NON_STATIC_APPS = [ 'pydis_site.apps.api', 'pydis_site.apps.staff', -] if not env("STATIC_BUILD") else [] +] if not STATIC_BUILD else [] INSTALLED_APPS = [ *NON_STATIC_APPS, @@ -98,25 +139,29 @@ INSTALLED_APPS = [ if not env("BUILDING_DOCKER"): INSTALLED_APPS.append("django_prometheus") -NON_STATIC_MIDDLEWARE = [ - 'django_prometheus.middleware.PrometheusBeforeMiddleware', -] if not env("STATIC_BUILD") else [] - -# Ensure that Prometheus middlewares are first and last here. -MIDDLEWARE = [ - *NON_STATIC_MIDDLEWARE, - - 'django.middleware.security.SecurityMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - - 'django_prometheus.middleware.PrometheusAfterMiddleware' -] +if STATIC_BUILD: + # The only middleware required during static builds + MIDDLEWARE = [ + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + ] +else: + # Ensure that Prometheus middlewares are first and last here. + MIDDLEWARE = [ + 'django_prometheus.middleware.PrometheusBeforeMiddleware', + + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + + 'django_prometheus.middleware.PrometheusAfterMiddleware' + ] ROOT_URLCONF = 'pydis_site.urls' @@ -145,7 +190,7 @@ WSGI_APPLICATION = 'pydis_site.wsgi.application' DATABASES = { 'default': env.db(), 'metricity': env.db('METRICITY_DB_URL'), -} if not env("STATIC_BUILD") else {} +} if not STATIC_BUILD else {} # Password validation # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators @@ -170,7 +215,6 @@ AUTH_PASSWORD_VALIDATORS = [ LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' USE_I18N = True -USE_L10N = True USE_TZ = True # Static files (CSS, JavaScript, Images) @@ -197,6 +241,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 = { @@ -313,3 +360,11 @@ 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") + +# Source files url for 'Edit on GitHub' link on content articles +CONTENT_SRC_URL = ( + "https://github.com/python-discord/site/tree/main/pydis_site/apps/content/resources/" +) diff --git a/pydis_site/static/css/base/base.css b/pydis_site/static/css/base/base.css index 4b36b7ce..79a8a92d 100644 --- a/pydis_site/static/css/base/base.css +++ b/pydis_site/static/css/base/base.css @@ -54,7 +54,6 @@ main.site-content { padding-left: 2.5rem; padding-right: 2.5rem; background-color: #697ec4ff; - margin-left: 0.5rem; transition: all 0.2s cubic-bezier(.25,.8,.25,1); overflow: hidden; } @@ -80,7 +79,7 @@ main.site-content { #netcup-logo { padding-left: 15px; - background: url(https://www.netcup-wiki.de/static/assets/images/netcup_logo_white.svg) no-repeat center; + background: url(/static/images/sponsors/netcup-white.svg) no-repeat center; background-size: 60px; background-position: 0px 3px; color: #00000000; diff --git a/pydis_site/static/css/content/color.css b/pydis_site/static/css/content/color.css new file mode 100644 index 00000000..f4801c28 --- /dev/null +++ b/pydis_site/static/css/content/color.css @@ -0,0 +1,7 @@ +.content .fa-github { + color: black; +} + +.content .fa-github:hover { + color: #7289DA; +} diff --git a/pydis_site/static/css/content/page.css b/pydis_site/static/css/content/page.css index d831f86d..239f2809 100644 --- a/pydis_site/static/css/content/page.css +++ b/pydis_site/static/css/content/page.css @@ -2,10 +2,29 @@ padding: 1rem; } +#edit-on-github { + margin-left: 1rem; +} + +@media (max-width: 600px) { + #edit-on-github { + display: none; + } +} + i.has-icon-padding { padding: 0 10px 25px 0; } +.card.github-card { + height: 100%; + display: flex; + flex-direction: column; +} + +.card.github-card .card-content { + flex: 1; +} /* * Move padding padding from <pre> tag to hljs <code> tags so the padding * space is colored the same as the background of hljs <code> blocks. diff --git a/pydis_site/static/css/content/tag.css b/pydis_site/static/css/content/tag.css new file mode 100644 index 00000000..79795f9e --- /dev/null +++ b/pydis_site/static/css/content/tag.css @@ -0,0 +1,13 @@ +.content a * { + /* This is the original color, but propagated down the chain */ + /* which allows for elements inside links, such as codeblocks */ + color: #7289DA; +} + +.content a *:hover { + color: dimgray; +} + +span.update-time { + text-decoration: black underline dotted; +} 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 b8456e38..96d06111 100644 --- a/pydis_site/static/css/resources/resources.css +++ b/pydis_site/static/css/resources/resources.css @@ -73,6 +73,11 @@ display: block; 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; @@ -86,6 +91,35 @@ 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 { @@ -125,7 +159,6 @@ i.is-primary { color: #7289DA; } - /* Set default display to inline-flex, for centering. */ span.filter-box-tag { display: none; @@ -181,7 +214,8 @@ button.delete.is-info::after { /* Give outlines to active tags */ span.filter-box-tag, -span.resource-tag.active { +span.resource-tag.active, +.tag.search-query { outline-width: 1px; outline-style: solid; } @@ -245,6 +279,9 @@ span.resource-tag.active.has-background-info-light { padding-top: 4px; padding-bottom: 4px; } + .tag.search-query .inner { + max-width: 16.2rem; + } } /* Constrain the width of the filterbox */ 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..d980ab4c --- /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..41ed555c --- /dev/null +++ b/pydis_site/static/images/content/discord_colored_messages/result.png diff --git a/pydis_site/static/images/content/fix-ssl-certificate/pem.png b/pydis_site/static/images/content/fix-ssl-certificate/pem.png Binary files differnew file mode 100644 index 00000000..face520f --- /dev/null +++ b/pydis_site/static/images/content/fix-ssl-certificate/pem.png diff --git a/pydis_site/static/images/content/help_channels/available_channels.png b/pydis_site/static/images/content/help_channels/available_channels.png Binary files differdeleted file mode 100644 index 0b9cfd03..00000000 --- a/pydis_site/static/images/content/help_channels/available_channels.png +++ /dev/null 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 differdeleted file mode 100644 index 05f6ec7d..00000000 --- a/pydis_site/static/images/content/help_channels/available_message.png +++ /dev/null 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 differdeleted file mode 100644 index 2c53de87..00000000 --- a/pydis_site/static/images/content/help_channels/dormant_channels.png +++ /dev/null diff --git a/pydis_site/static/images/content/help_channels/help-system-category.png b/pydis_site/static/images/content/help_channels/help-system-category.png Binary files differnew file mode 100644 index 00000000..bea5a92c --- /dev/null +++ b/pydis_site/static/images/content/help_channels/help-system-category.png diff --git a/pydis_site/static/images/content/help_channels/new-post-button.png b/pydis_site/static/images/content/help_channels/new-post-button.png Binary files differnew file mode 100644 index 00000000..4ceabf0f --- /dev/null +++ b/pydis_site/static/images/content/help_channels/new-post-button.png diff --git a/pydis_site/static/images/content/help_channels/new-post-form.png b/pydis_site/static/images/content/help_channels/new-post-form.png Binary files differnew file mode 100644 index 00000000..3e90bf7d --- /dev/null +++ b/pydis_site/static/images/content/help_channels/new-post-form.png diff --git a/pydis_site/static/images/content/help_channels/newly-created-thread-example.png b/pydis_site/static/images/content/help_channels/newly-created-thread-example.png Binary files differnew file mode 100644 index 00000000..d7b1eed4 --- /dev/null +++ b/pydis_site/static/images/content/help_channels/newly-created-thread-example.png diff --git a/pydis_site/static/images/content/help_channels/occupied_channels.png b/pydis_site/static/images/content/help_channels/occupied_channels.png Binary files differdeleted file mode 100644 index 6ccb4ed6..00000000 --- a/pydis_site/static/images/content/help_channels/occupied_channels.png +++ /dev/null diff --git a/pydis_site/static/images/content/help_channels/question-example.png b/pydis_site/static/images/content/help_channels/question-example.png Binary files differnew file mode 100644 index 00000000..da181351 --- /dev/null +++ b/pydis_site/static/images/content/help_channels/question-example.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/content/regenerating_token.jpg b/pydis_site/static/images/content/regenerating_token.jpg Binary files differnew file mode 100644 index 00000000..7b2588dc --- /dev/null +++ b/pydis_site/static/images/content/regenerating_token.jpg 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/cj10_2023_banner2.png b/pydis_site/static/images/events/cj10_2023_banner2.png Binary files differnew file mode 100644 index 00000000..92cae4f6 --- /dev/null +++ b/pydis_site/static/images/events/cj10_2023_banner2.png diff --git a/pydis_site/static/images/events/cj10_2023_banner3.png b/pydis_site/static/images/events/cj10_2023_banner3.png Binary files differnew file mode 100644 index 00000000..9d808f07 --- /dev/null +++ b/pydis_site/static/images/events/cj10_2023_banner3.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/sponsors/netcup-white.svg b/pydis_site/static/images/sponsors/netcup-white.svg new file mode 100755 index 00000000..27541788 --- /dev/null +++ b/pydis_site/static/images/sponsors/netcup-white.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 305 87"><title>netcup_logo_white</title><path d="M254.712,77.153h5.837V87H242.932V26.254h-5.293V16.4h34.9c7.02,0,10.481,3.543,10.481,10.732v29.22c0,6.968-3.567,10.511-10.8,10.511h-10.7V57.016H268c2.376,0,3.568-1.222,3.568-3.766V30.126c0-2.552-1.192-3.872-3.461-3.872h-13.4ZM232.467,66.862H200.054c-7.028,0-10.588-3.543-10.588-10.625V25.145h-5.187V16.4h16.852V52.807c0,2.765,1.3,4.209,3.782,4.209h10.48V16.4h11.673V57.016h5.4Zm-55.9,0H140.369V34.221c0-11.841,6.264-17.822,18.907-17.822H176.67V33.335H166.625V26.254h-8.212c-4.324,0-6.486,2.429-6.486,7.187V57.016h24.636Zm-45.063,0H118.636c-5.722,0-8.533-3.1-8.533-9.182V26.254h-6.486V16.4H110.1V.906h11.558V16.4h9.724v9.855h-9.724V53.471c0,2.544,1.405,3.766,4.11,3.651H128.9a19.654,19.654,0,0,0,2.6-.106ZM97.116,47.5H71.074v6.532c0,1.986.97,2.987,2.811,2.987H95.817v9.846H69.348c-6.486,0-9.725-3.208-9.725-9.74V27.353c0-7.3,3.782-10.954,11.237-10.954H86.635c6.914,0,10.374,3.43,10.481,10.4Zm-26.042-9.74h14.7V29.462c0-2.109-.978-3.208-2.918-3.208H74.206c-2.055,0-3.132,1.214-3.132,3.651Zm-38.127,29.1V29.126c0-1.88-1.085-2.872-3.247-2.872H17.386V66.862H5.714V26.254H.313V16.4H34.994c6.379,0,9.618,3.323,9.618,10.076V57.016h5.4v9.846Z" style="fill:#fff;fill-rule:evenodd"/><path d="M295.6,16.007a7.05,7.05,0,0,1-7.211-6.838A7.29,7.29,0,0,1,295.6,1.911c3.927,0,7.7,3.278,7.7,7.258,0,3.8-3.774,6.838-7.7,6.838M287,9.169a8.4,8.4,0,0,0,8.6,8.228c4.928,0,9.091-3.706,9.091-8.228A8.952,8.952,0,0,0,295.6.53,8.608,8.608,0,0,0,287,9.169m6.577-.535V5.579h2.215c1.139,0,2.323.161,2.323,1.481,0,1.506-1.307,1.574-2.788,1.574ZM292.182,14h1.39V10.025h1.75L298.255,14h1.436L296.85,9.91a2.6,2.6,0,0,0,2.65-2.781c0-1.986-1.1-2.941-3.414-2.941h-3.9Z" style="fill:#fff;fill-rule:evenodd"/></svg> diff --git a/pydis_site/static/js/content/listing.js b/pydis_site/static/js/content/listing.js new file mode 100644 index 00000000..4b722632 --- /dev/null +++ b/pydis_site/static/js/content/listing.js @@ -0,0 +1,41 @@ +/** + * Trim a tag listing to only show a few lines of content. + */ +function trimTag() { + const containers = document.getElementsByClassName("tag-container"); + for (const container of containers) { + if (container.textContent.startsWith("Contains the following tags:")) { + // Tag group, no need to trim + continue; + } + + // Remove every element after the first two paragraphs + while (container.children.length > 2) { + container.removeChild(container.lastChild); + } + + // Trim down the elements if they are too long + const containerLength = container.textContent.length; + if (containerLength > 300) { + if (containerLength - container.firstChild.textContent.length > 300) { + // The first element alone takes up more than 300 characters + container.removeChild(container.lastChild); + } + + let last = container.lastChild.lastChild; + while (container.textContent.length > 300 && container.lastChild.childNodes.length > 0) { + last = container.lastChild.lastChild; + last.remove(); + } + + if (container.textContent.length > 300 && (last instanceof HTMLElement && last.tagName !== "CODE")) { + // Add back the final element (up to a period if possible) + const stop = last.textContent.indexOf("."); + last.textContent = last.textContent.slice(0, stop > 0 ? stop + 1: null); + container.lastChild.appendChild(last); + } + } + } +} + +trimTag(); diff --git a/pydis_site/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 index 508849e1..d6cc8128 100644 --- a/pydis_site/static/js/resources/resources.js +++ b/pydis_site/static/js/resources/resources.js @@ -8,6 +8,13 @@ var activeFilters = { 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); @@ -25,6 +32,7 @@ function removeAllFilters() { "payment-tiers": [], difficulty: [] }; + $("#resource-search input").val(""); updateUI(); } @@ -51,6 +59,13 @@ function noFilters() { 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); @@ -62,11 +77,13 @@ function deserializeURLParams() { // Update the corresponding filter UI, so it reflects the internal state. let filterAdded = false; $(paramFilterArray).each(function(_, filter) { - // Make sure the filter is valid before we do anything. + // 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}']`); @@ -91,10 +108,23 @@ function deserializeURLParams() { }); } +/* 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() { - // If there's nothing in the filters, we don't want anything in the URL. - if (noFilters()) { + 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; } @@ -107,10 +137,44 @@ function updateURL() { } }); + // 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'); @@ -118,19 +182,31 @@ function updateUI() { 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()) { - resources.show(); + // 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(); - noTagsSelected.show(); - closeFiltersButton.hide(); resourceTags.removeClass("active"); $(`.filter-checkbox:checked`).prop("checked", false); - $(".no-resources-found").hide(); + updateDuckies(); + return; } else { // Hide everything @@ -158,9 +234,8 @@ function updateUI() { } // Otherwise, hide everything and then filter the resources to decide what to show. - let hasMatches = false; resources.hide(); - resources.filter(function() { + let filteredResources = resources.filter(function() { let validation = { topics: false, type: false, @@ -187,20 +262,22 @@ function updateUI() { // If validation passes, show the resource. if (Object.values(validation).every(Boolean)) { - hasMatches = true; return true; } else { return false; } - }).show(); - + }); - // If there are no matches, show the no matches message - if (!hasMatches) { - $(".no-resources-found").show(); + // Run the items we've found through the search filter, if necessary. + if (searchQuery.length > 0) { + filterBySearch(filteredResources); } else { - $(".no-resources-found").hide(); + filteredResources.show(); + searchTag.hide(); } + + // Gotta update those duckies! + updateDuckies(); } // Executed when the page has finished loading. @@ -230,6 +307,11 @@ document.addEventListener("DOMContentLoaded", function () { 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)); diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html index 6577a4cf..f6a1bc26 100644 --- a/pydis_site/templates/base/navbar.html +++ b/pydis_site/templates/base/navbar.html @@ -74,6 +74,9 @@ <a class="navbar-item" href="{% url "resources:index" %}" > Resources </a> + <a class="navbar-item" href="{% url "content:pages" %}"> + Content + </a> <a class="navbar-item" href="{% url "events:index" %}"> Events </a> diff --git a/pydis_site/templates/content/base.html b/pydis_site/templates/content/base.html index 4a19a275..bda6d954 100644 --- a/pydis_site/templates/content/base.html +++ b/pydis_site/templates/content/base.html @@ -1,5 +1,6 @@ {% extends 'base/base.html' %} {% load static %} +{% load page_src %} {% block title %}{{ page_title }}{% endblock %} {% block head %} @@ -8,6 +9,10 @@ <meta property="og:description" content="{{ page_description }}" /> <link rel="stylesheet" href="{% static "css/content/page.css" %}"> <link rel="stylesheet" href="{% static "css/collapsibles.css" %}"> + <link rel="stylesheet" + href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/styles/atom-one-dark.min.css"> + <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/highlight.min.js"></script> + <script>hljs.highlightAll();</script> <script src="{% static "js/collapsibles.js" %}"></script> {% endblock %} @@ -25,17 +30,28 @@ <li class="is-active"><a href="#">{{ page_title }}</a></li> </ul> </nav> + </div> + <div class="is-flex is-pulled-right is-right"> {# Sub-Article dropdown for category pages #} {% if subarticles %} {% include "content/dropdown.html" %} {% endif %} + {# Edit on GitHub for content articles #} + {% if page and not tag %} + <div id="edit-on-github"> + <a href="{{ request.path | page_src_url }}"> + <i class="fa-solid fa-pencil"></i> + Edit on GitHub + </a> + </div> + {% endif %} </div> </section> <section class="section"> <div class="container"> <div class="content"> - <h1 class="title">{{ page_title }}</h1> + <h1 class="title">{% block title_element %}{{ page_title }}{% endblock %}</h1> {% block page_content %}{% endblock %} </div> </div> diff --git a/pydis_site/templates/content/dropdown.html b/pydis_site/templates/content/dropdown.html index 13c89c68..60560547 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" style="z-index: 1"> +<div class="dropdown" 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/content/listing.html b/pydis_site/templates/content/listing.html index 9dac17d9..c21cea74 100644 --- a/pydis_site/templates/content/listing.html +++ b/pydis_site/templates/content/listing.html @@ -1,6 +1,22 @@ +{# Base navigation screen for resources #} {% extends 'content/base.html' %} +{% load static %} + +{# Show a GitHub button on tag pages #} +{% if is_tag_listing %} +{% block title_element %} + <link rel="stylesheet" href="{% static "css/content/color.css" %}"> + <div class="level"> + <div class="level-left">{{ block.super }}</div> + <div class="level-right"> + <a class="level-item fab fa-github" href="{{ tag_url }}"></a> + </div> + </div> +{% endblock %} +{% endif %} {% block page_content %} + {# Nested Categories #} {% for category, data in categories.items %} <div class="box has-background-white-bis" style="max-width: 800px;"> <span class="icon is-size-4 is-medium"> @@ -13,15 +29,22 @@ <p class="is-italic">{{ data.description }}</p> </div> {% endfor %} + + {# Single Pages #} {% for page, data in pages.items %} <div class="box has-background-white-bis" style="max-width: 800px;"> <span class="icon is-size-4 is-medium"> <i class="{{ data.icon|default:"fab fa-python" }} is-size-3 is-black has-icon-padding" aria-hidden="true"></i> </span> - <a href="{% url "content:page_category" location=path|add:page %}"> + <a href="{% url app_name location=path|add:page %}"> <span class="is-size-4 has-text-weight-bold">{{ data.title }}</span> </a> - <p class="is-italic">{{ data.description }}</p> + {% if is_tag_listing %} + <div class="tag-container">{{ data.description | safe }}</div> + {% else %} + <p class="is-italic">{{ data.description }}</p> + {% endif %} </div> {% endfor %} + <script src="{% static 'js/content/listing.js' %}"></script> {% endblock %} diff --git a/pydis_site/templates/content/page.html b/pydis_site/templates/content/page.html index c870b6bd..0d7087dd 100644 --- a/pydis_site/templates/content/page.html +++ b/pydis_site/templates/content/page.html @@ -1,13 +1,5 @@ {% extends 'content/base.html' %} -{% block head %} - {{ block.super }} - <link rel="stylesheet" - href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/styles/atom-one-dark.min.css"> - <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/highlight.min.js"></script> - <script>hljs.initHighlightingOnLoad();</script> -{% endblock %} - {% block page_content %} {% if relevant_links or toc %} <div class="columns is-variable is-8"> diff --git a/pydis_site/templates/content/tag.html b/pydis_site/templates/content/tag.html new file mode 100644 index 00000000..fa9e44f5 --- /dev/null +++ b/pydis_site/templates/content/tag.html @@ -0,0 +1,40 @@ +{% extends "content/page.html" %} +{% load static %} + +{% block head %} + {{ block.super }} + <link rel="stylesheet" href="{% static 'css/content/color.css' %}"/> + <link rel="stylesheet" href="{% static 'css/content/tag.css' %}"/> + <title>{{ tag.name }}</title> +{% endblock %} + +{% block title_element %} + <div class="level mb-2"> + <div class="level-left">{{ block.super }}</div> + <div class="level-right"> + <a class="level-item fab fa-github" href="{{ tag.url }}"></a> + </div> + </div> + + <div class="dropdown is-size-6 is-hoverable"> + <div class="dropdown-trigger "> + <a aria-haspopup="menu" href="{{ tag.last_commit.url }}"> + <span class="update-time"> + Last Updated: {{ tag.last_commit.date | date:"F j, Y g:i A e" }} + </span> + </a> + </div> + <div class="dropdown-menu"> + <div class="dropdown-content"> + <div class="dropdown-item">Last edited by:</div> + {% for user in tag.last_commit.format_authors %} + <div class="dropdown-item">{{ user }}</div> + {% endfor %} + <div class="dropdown-divider"></div> + {% for line in tag.last_commit.lines %} + <div class="dropdown-item">{{ line }}</div> + {% endfor %} + </div> + </div> + </div> +{% endblock %} diff --git a/pydis_site/templates/events/index.html b/pydis_site/templates/events/index.html index ed1bbfed..2fbf0b85 100644 --- a/pydis_site/templates/events/index.html +++ b/pydis_site/templates/events/index.html @@ -8,11 +8,11 @@ {% block event_content %} <div class="box has-background-white-bis"> - <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> + <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> + <iframe style="max-width: 100%;" width="560" height="315" src="https://www.youtube.com/embed/8fbZsGrqBzo" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe> </div> <div class="box has-background-white-bis"> diff --git a/pydis_site/templates/events/pages/code-jams/10/_index.html b/pydis_site/templates/events/pages/code-jams/10/_index.html new file mode 100644 index 00000000..388f0df0 --- /dev/null +++ b/pydis_site/templates/events/pages/code-jams/10/_index.html @@ -0,0 +1,119 @@ +{% extends "events/base_sidebar.html" %} + +{% load static %} + +{% block title %}Code Jam 2023{% 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="#">Code Jam 2023</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="winners"><a href="#winners">Code Jam Winners</a></h3> + <p>Congratulations to our winners and the two runner ups! Check out their projects below.</p> + + <h4 class="mt-5 mb-2"><i class="fa fa-trophy"></i> Dynamic Typists: next level CAPTCHA</h4> + <p class="my-1"><em>tfblunt, the_outlaw_josey_wales, istalantar, maxyodedara5, ooliver</em></p> + <p class="my-1"> + Taking captchas to the next level in an effort to foil some of the new AIs. The captchas are image based with a twist. + </p> + <p> + <a href="https://github.com/thijsfranck/the-dynamic-typists" title="The Dynamic Typists GitHub Repository" target="_blank" rel="noopener"><i class="fa fa-github"></i> GitHub Repository</a> + <br/> + + </p> + + <h4 class="mt-5 mb-2"><i class="fa fa-medal"></i> Magic Methods: Code Stego</h4> + <p class="my-1"><em>moltensteel, _h_s_p_, ilovetensor, fusionx96, koushireo</em></p> + <p class="my-1"> + Given a snippet of code, create an image of the code to easily share and also embed the code into the image of the code itself. + </p> + <p> + <a href="https://github.com/StoneSteel27/The-Magic-Methods" title="The Magic Methods GitHub Repository" target="_blank" rel="noopener"><i class="fa fa-github"></i> GitHub Repository</a> + <br/> + </p> + + <h4 class="mt-5 mb-2"><i class="fa fa-medal"></i> Async Aggregators: Digital Shadows</h4> + <p class="my-1"><em>flowglow, leo.the.lion, dfebs, avongard, chronsfu, _sardines_</em></p> + <p class="my-1"> + Image manipulation puzzle game. Adjust image manipulation sliders to find secret codes. + <p> + <a href="https://github.com/Flow-Glow/Code-Jam-2023-Async-Aggregators" title="The Async Aggregators GitHub Repository" target="_blank" rel="noopener"><i class="fa fa-github"></i> GitHub Repository</a> + <br/> + </p> + + <h3 id="submissions"><a href="#submissions">Submissions</a></h3> + <p> + By the end of the jam, 17 teams made project submissions. Check them all out here: + <div class="has-text-centered"><a class="button is-link" href="submissions">View Submissions</a></div> + </p> + + <h3 id="important-dates"><a href="#important-dates">Important Dates</a></h3> + <ul> + <li>Wednesday, August 16 - The Qualifier is released</li> + <li>Wednesday, August 23 - The Qualifier closes</li> + <li>Wednesday, August 23 - Voting for the theme opens</li> + <li>Thursday, August 31 - Code Jam Begins</li> + <li>Sunday, September 10 - Coding portion of the jam ends</li> + <li>Thursday, September 14 - Code Jam submissions are closed</li> + </ul> + + <h3 id="qualifier"><a href="#how-to-join">The Qualifier</a></h3> + <p> + The qualifier is a coding challenge that you are required to complete before registering for the code jam. + This is meant as a basic assessment of your skills to ensure you have enough Python knowledge to effectively contribute in a team environment. + + The qualifier will be released on August 16th. + </p> + + <h3 id="technology"><a href="#technology">Technology</a></h3> + <p> + The chosen technology for this year is <strong>Image Processing and Manipulation</strong>. + Each team must make use of <a href="{% url "events:page" path="code-jams/10/frameworks" %}">the approved frameworks</a> to create an app that strongly incorporates Image Processing and Manipulation. + </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/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> + +{% endblock %} + +{% block sidebar %} + + {% include "events/sidebar/code-jams/10.html" %} + +{% endblock %} diff --git a/pydis_site/templates/events/pages/code-jams/10/frameworks.html b/pydis_site/templates/events/pages/code-jams/10/frameworks.html new file mode 100644 index 00000000..b5bc7204 --- /dev/null +++ b/pydis_site/templates/events/pages/code-jams/10/frameworks.html @@ -0,0 +1,239 @@ +{% extends "events/base_sidebar.html" %} + +{% load static %} + +{% block title %}Summer Code Jam 2023{% 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/10" %}">Code Jam 2023</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 (or multiple) 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 in the Discord server. + + </p> + <p> + Other libraries can be used to help build your application, but if their primary purpose is image manipulation or processing then they must be approved or from this list. + In particular, if you'd like to use a graph/plot creating library similar to Matplotlib or a machine learning framework/model, double check with us before deciding to use it. + </p> + + <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">Pillow</p> + <p> + Pillow is a fork of the Python Imaging Library (PIL). The Python Imaging Library adds image processing capabilities to your Python interpreter. + This library provides extensive file format support, an efficient internal representation, and fairly powerful image processing capabilities. + The core image library is designed for fast access to data stored in a few basic pixel formats. It should provide a solid foundation for a general image processing tool. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://python-pillow.org/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/python-pillow/Pillow" 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">OpenCV-Python</p> + <p> + OpenCV (Open Source Computer Vision Library: http://opencv.org) is an open-source library that includes several hundreds of computer vision algorithms. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://docs.opencv.org/4.x/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/opencv/opencv-python" 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">NumPy</p> + <p> + NumPy is the fundamental package for scientific computing with Python. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://numpy.org/doc/stable/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/numpy/numpy" 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">scikit-image</p> + <p> + scikit-image is a collection of algorithms for image processing. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://scikit-image.org/docs/stable/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/scikit-image/scikit-image" 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">matplotlib</p> + <p> + Matplotlib is a comprehensive library for creating static, animated, and interactive visualizations in Python. Matplotlib makes easy things easy and hard things possible. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://matplotlib.org/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/matplotlib/matplotlib" 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">PyQtGraph</p> + <p> + PyQtGraph is intended for use in mathematics / scientific / engineering applications. + Despite being written entirely in Python, the library is fast due to its heavy leverage of numpy for number crunching, Qt's GraphicsView framework for 2D display, and OpenGL for 3D display. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://www.pyqtgraph.org/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/pyqtgraph/pyqtgraph" 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">SciPy</p> + <p> + SciPy is a collection of mathematical algorithms and convenience functions built on NumPy. + It adds significant power to Python by providing the user with high-level commands and classes for manipulating and visualizing data. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://docs.scipy.org/doc/scipy/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/scipy/scipy" 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">Mahotas</p> + <p> + Mahotas is a computer vision and image processing library for Python. + It includes many algorithms implemented in C++ for speed while operating in numpy arrays and with a very clean Python interface. + Mahotas currently has over 100 functions for image processing and computer vision and it keeps growing. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://mahotas.readthedocs.io/en/latest/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/luispedro/mahotas" 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">VisPy</p> + <p> + VisPy is a high-performance interactive 2D/3D data visualization library. + VisPy leverages the computational power of modern Graphics Processing Units (GPUs) through the OpenGL library to display very large datasets. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://vispy.org/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/vispy/vispy" 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">bokeh</p> + <p> + Bokeh is an interactive visualization library for modern web browsers. + It provides elegant, concise construction of versatile graphics and affords high-performance interactivity across large or streaming datasets. + Bokeh can help anyone who wants to create interactive plots, dashboards, and data applications quickly and easily. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://docs.bokeh.org/en/latest/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/bokeh/bokeh" 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">ffmpeg-python</p> + <p> + A Python wrapper for the powerful FFmpeg software. + </p> + <p> + ⚠ You can use other ffmpeg Python bindings, but both with this one and others, make sure that it works well with newer Python versions and be mindful of issues reported in their repositories. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://kkroening.github.io/ffmpeg-python/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/kkroening/ffmpeg-python" 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">imageio</p> + <p> + Imageio is a Python library that provides an easy interface to read and write a wide range of image data, including animated images, video, volumetric data, and scientific formats. + It is cross-platform, runs on Python 3.8+, and is easy to install. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://imageio.readthedocs.io/en/stable/" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/imageio/imageio" class="card-footer-item"><i class="fab fa-github"></i> GitHub</a> + </div> + </div> + + <h4 id="can-i-use-ml"><a href="#can-i-use-ml">Q: Can I use machine learning libraries?</a></h4> + <p> + A: Yes, you can use libraries such as scikit-learn, PyTorch, TensorFlow, etc. + <b>However</b>: + </p> + <ol type="1"> + <li>Your project should be easy to install and run, so it can't require a dedicated GPU or otherwise significant computational resources.</li> + <li>This might prove to be a barrier for your teammates to contribute to the project if they're not knowledgeable in the field.</li> + <li>We would still like to see more "classical" image processing techniques. This isn't an ML code jam.</li> + <li>If you use pretrained models you need to specify where you took them from, and they need to be compatible with the project license.</li> + </ol> + +{% endblock %} + +{% block sidebar %} + + {% include "events/sidebar/code-jams/10.html" %} + +{% endblock %} diff --git a/pydis_site/templates/events/pages/code-jams/10/rules.html b/pydis_site/templates/events/pages/code-jams/10/rules.html new file mode 100644 index 00000000..0bbb47d0 --- /dev/null +++ b/pydis_site/templates/events/pages/code-jams/10/rules.html @@ -0,0 +1,85 @@ +{% extends "events/base_sidebar.html" %} + +{% block title %}Summer Code Jam 2023{% 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/10" %}">Code Jam 2023</a></li> + <li class="is-active"><a href="#">Rules</a></li> +{% endblock %} + +{% block event_content %} +<ol> + <li><p>Your solution must use one or more of the approved frameworks. 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> + <strong>The core of your project must focus and incorporate image processing and manipulation</strong>. + </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-2023</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> + <li> + <p> + Any images or videos used must have one of the following licenses and follow the licensing guidelines: + <ul> + <li>Public Domain License</li> + <li>Copyright Free Image License</li> + <li>CC-BY (Creative Commons "By")</li> + <li>CC-BY-SA (Creative Commons "Share Alike")</li> + </ul> + </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/10.html" %} + +{% endblock %} diff --git a/pydis_site/templates/events/pages/code-jams/10/submissions.html b/pydis_site/templates/events/pages/code-jams/10/submissions.html new file mode 100644 index 00000000..de2e2d95 --- /dev/null +++ b/pydis_site/templates/events/pages/code-jams/10/submissions.html @@ -0,0 +1,178 @@ +{% extends "events/base_sidebar.html" %} + +{% load static %} + +{% block title %}Code Jam 2023{% 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/10" %}">Code Jam 2022</a></li> + <li class="is-active"><a href="#">Submissions</a></li> +{% endblock %} + +{% block event_content %} + <p> + Below is a list of all projects submitted by the end of Summer Code Jam 2022 + </p> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Async Aggregators</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/Flow-Glow/Code-Jam-2023-Async-Aggregators" title="The Async Aggregators">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Benevolent Dictators</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/ransomw/the-benevolent-dictators" title="The Benevolent Dictators">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Dot Operators</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/LukeHankey/the-dot-operators/tree/readme" title="The Dot Operators">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Dynamic Typists</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/thijsfranck/the-dynamic-typists" title="The Dynamic Typists">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Emerging Exceptions</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/FloncDev/emerging-exceptions" title="The Emerging Exceptions">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Expected Indents</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/BUSH222/cj-The-Expected-Indents" title="The Expected Indents">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Itinerant Iterators</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/smileyface12349/itinerant-iterators" title="The Itinerant Iterators">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Magic Methods</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/StoneSteel27/The-Magic-Methods" title="The Magic Methods">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Missing Dependencies</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/Bobby-McBobface/the-missing-dependencies" title="The Missing Dependencies">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Monkey Patches</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/mache102/cj10-monkey-patches" title="The Monkey Patches">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Neverending Loops</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/inkontoasty/the-neverending-loops" title="The Neverending Loops">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Pickled Peps</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/A5rocks/code-jam-10" title="The Pickled Peps">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Readable Regexes</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/Artemis21/pydis-jam23" title="The Readable Regexes">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Rubber Duck Debuggers</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/nozwock/code-jam-the-rubber-duck-debuggers" title="The Rubber Duck Debuggers">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Thick Wrappers</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/DavidLlanio/cj10_the_thick_wrappers/" title="The Thick Wrappers">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Top Level Walruses</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/lemonyte/the-top-level-walruses" title="The Top Level Walruses">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">The Wheels of Cheese</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/kronifer/code-jam-10" title="The Wheels of Cheese">GitHub</a></p> + </div> + </div> + +{% endblock %} + +{% block sidebar %} + + {% include "events/sidebar/code-jams/9.html" %} + +{% endblock %} diff --git a/pydis_site/templates/events/pages/code-jams/8/_index.html b/pydis_site/templates/events/pages/code-jams/8/_index.html index bbfe654a..0d613e2c 100644 --- a/pydis_site/templates/events/pages/code-jams/8/_index.html +++ b/pydis_site/templates/events/pages/code-jams/8/_index.html @@ -26,7 +26,7 @@ <h4 class="mt-5 mb-2"><i class="fa fa-trophy"></i> Perceptive Porcupines: WTPython!?</h4> <p class="my-1"><em>VV, Poppinawhile, ethansocal, Jeff Z, Cohan, ¯\_(ツ)_/¯</em></p> <p class="my-1"> - What the Python (wtpython) is a simple terminal user interface that allows you to explore relevant answers on Stackoverflow without leaving your terminal or IDE. When you get an error, all you have to do is swap python for wtpython. When your code hits an error, you'll see a textual interface for exploring relevant answers allowing you to stay focused and ship faster! + What the Python (wtpython) is a simple terminal user interface that allows you to explore relevant answers on Stackoverflow without leaving your terminal or IDE. When you get an error, all you have to do is swap Python for wtpython. When your code hits an error, you'll see a textual interface for exploring relevant answers allowing you to stay focused and ship faster! </p> <p> <a href="https://www.youtube.com/watch?v=DV3uMdsw9KE" title="Perceptive Porcupines Demo Video" target="_blank" rel="noopener"><i class="fa fa-video"> </i> Demo video</a> @@ -89,7 +89,7 @@ <h3 id="qualifier"><a href="#qualifier">The Qualifier</a></h3> <p> The qualifier is a coding challenge that you are required to complete before registering for the code jam. - This is meant as a basic assessment of your skills to ensure you have enough python knowledge to effectively contribute in a team environment. + This is meant as a basic assessment of your skills to ensure you have enough Python knowledge to effectively contribute in a team environment. </p> <p class="has-text-centered"><a class="button is-link" href="https://github.com/python-discord/cj8-qualifier" target="_blank" rel="noopener">View the Qualifier</a></p> <p> diff --git a/pydis_site/templates/events/pages/code-jams/8/frameworks.html b/pydis_site/templates/events/pages/code-jams/8/frameworks.html index 1c02e38a..d8fbe96f 100644 --- a/pydis_site/templates/events/pages/code-jams/8/frameworks.html +++ b/pydis_site/templates/events/pages/code-jams/8/frameworks.html @@ -103,7 +103,7 @@ <ul> <li><a href="https://python-prompt-toolkit.readthedocs.io/en/stable/" target="_blank">Documentation Link</a></li> <li><strong>Supports:</strong> Linux, Mac, and Windows</li> - <li>Pure python library</li> + <li>Pure Python library</li> </ul> </div> <div class="column"> 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..5c094b22 --- /dev/null +++ b/pydis_site/templates/events/pages/code-jams/9/_index.html @@ -0,0 +1,168 @@ +{% 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="winners"><a href="#winners">Code Jam Winners</a></h3> + <p>Congratulations to our winners and the two runner ups! Check out their projects below.</p> + + <h4 class="mt-5 mb-2"><i class="fa fa-trophy"></i> Cerebral Centaurs: Drawn</h4> + <p class="my-1"><em>Palmirka, Tu®tle, collerek, Rortox</em></p> + <p class="my-1"> + Drawn is based on old school Pictionary game, with additional surprises waiting for the drawer each turn. + </p> + <p> + <a href="https://github.com/collerek/cerebral-centaurs" title="Cerebral Centaurs GitHub Repository" target="_blank" rel="noopener"><i class="fa fa-github"></i> GitHub Repository</a> + <br/> + + </p> + + <h4 class="mt-5 mb-2"><i class="fa fa-medal"></i> Kingly Kelpies: for level_id in range(14)</h4> + <p class="my-1"><em>Kingu, Falkan, Bamboodja, Redriel, Vinyzu</em></p> + <p class="my-1"> + A simple multiplayer puzzle game, in which your aim is to reach the goal by pressing buttons, standing on plates and moving boxes. + </p> + <p> + <a href="https://www.youtube.com/watch?v=-VQ_ijuo-Mg" title="Kingly Kelpies Demo Video" target="_blank" rel="noopener"><i class="fa fa-video"> </i> Demo video</a> + <br/> + <a href="https://github.com/Kingly-elpies/KinglyKelpies" title="Kingly Kelpies GitHub Repository" target="_blank" rel="noopener"><i class="fa fa-github"></i> GitHub Repository</a> + <br/> + </p> + + <h4 class="mt-5 mb-2"><i class="fa fa-medal"></i> Logical Leprechauns: Curse of the Mites</h4> + <p class="my-1"><em>Fyx, Aboo Minister, A5rocks, Bautista, ~tylerr</em></p> + <p class="my-1"> + Curse of the Mites is a text based MUD (multi-user dungeon) in which you play as a caterpillar whose goal is to grow into a butterfly to escape the forest cursed by mites. + <p> + <a href="https://github.com/AbooMinister25/Curse-of-the-Mites" title="Logical Leprechauns GitHub Repository" target="_blank" rel="noopener"><i class="fa fa-github"></i> GitHub Repository</a> + <br/> + </p> + + <h3 id="submissions"><a href="#submissions">Submissions</a></h3> + <p> + By the end of the jam, 20+ teams made project submissions. Check them all out here: + <div class="has-text-centered"><a class="button is-link" href="submissions">View Submissions</a></div> + </p> + + <h3 id="important-dates"><a href="#important-dates">Important Dates</a></h3> + <ul> + <li>Saturday, June 18 - Form to submit theme suggestions opens</li> + <li>Wednesday, June 29 - The Qualifier is released</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="qualifier"><a href="#how-to-join">The Qualifier</a></h3> + <p> + The qualifier is a coding challenge that you are required to complete before registering for the code jam. + This is meant as a basic assessment of your skills to ensure you have enough Python knowledge to effectively contribute in a team environment. + </p> + <p class="has-text-centered"><a class="button is-link" href="https://github.com/python-discord/code-jam-qualifier-9/" target="_blank" rel="noopener">View the Qualifier</a></p> + <p> + Please note the requirements for the qualifier. + <ul> + <li>The qualifier must be completed using Python 3.10</li> + <li>No external modules are allowed, only those available through the standard library.</li> + <li>The Qualifier must be submitted through the Code Jam sign-up form.</li> + </ul> + </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..b462c733 --- /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">aiohttp</p> + <p>aiohttp provides both a client and server WebSocket implementation, while avoiding callback-hell. + </p> + </div> + </div> + <div class="card-footer"> + <a href="https://docs.aiohttp.org/en/stable/client_quickstart.html#websockets" class="card-footer-item"><i class="fas fa-book"></i> Documentation</a> + <a href="https://github.com/aio-libs/aiohttp" 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">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> + + <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> + + +{% 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..9a28852f --- /dev/null +++ b/pydis_site/templates/events/pages/code-jams/9/rules.html @@ -0,0 +1,80 @@ +{% 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. 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> + <strong>The core of your project must use WebSockets as its communication protocol.</strong>. + This means that you are allowed to use other methods of communication where WebSockets cannot be implemented, however, that should be a non-significant portion of your project. + For example, serving static files for a website cannot be done over WebSockets and it does not pose as a significant portion of a project, therefore it is allowed. + </p> + + <p>This rule does not apply to databases and files when used for <i>storage purposes</i> even though that may be a significant portion of your project. Working with subprocesses (through stdin/stdout or <code>multiprocessing.Pool()</code>/<code>concurrent.futures.ProcessPoolExecutor()</code>) is also exempt from this rule.</p> + + <p>If you're unsure about your use of non-WebSocket communication, please reach out to the events team.</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/9/submissions.html b/pydis_site/templates/events/pages/code-jams/9/submissions.html new file mode 100644 index 00000000..e0139ce8 --- /dev/null +++ b/pydis_site/templates/events/pages/code-jams/9/submissions.html @@ -0,0 +1,232 @@ +{% 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="#">Submissions</a></li> +{% endblock %} + +{% block event_content %} + <p> + Below is a list of all projects submitted by the end of Summer Code Jam 2022 + </p> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Artful Angels</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/Artful-Angels/code-jam" title="Artful Angels">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Burly Barghests</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/jasonho1308/summer-code-jam-2022" title="Burly Barghests">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Cerebral Centaurs</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/collerek/cerebral-centaurs" title="Cerebral Centaurs">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Dynamic Dryads</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/Kronifer/code-jam-9" title="Dynamic Dryads">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Generous Giants</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/bast0006/cj9-generous-giants/" title="Generous Giants">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Genuine Djinn</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/srijal30/genuine-djinn" title="Genuine Djinn">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Gorgeous Ghouls</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/Gorgeous-Ghouls/Blak" title="Gorgeous Ghouls">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Heroic Hydra</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/mkadiya20/heroic-hydra" title="Heroic Hydra">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Kindly Kappa</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/Vthechamp22/kindly-kappa" title="Kindly Kappa">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Kingly Kelpies</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/Kingly-elpies/KinglyKelpies" title="Kingly Kelpies">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Logical Leprechauns</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/AbooMinister25/Curse-of-the-Mites" title="Logical Leprechauns">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Lucky Leucrota</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/lucky-leucrota/cj9-lucky-leucrota" title="Lucky Leucrota">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Muscular Mermaids</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/vonvic/Python-CodeJam-2022-MM" title="Muscular Mermaids">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Old-Fashioned Orcs</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/smileyface12349/old-fashioned-orcs" title="Old-Fashioned Orcs">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Sassy Snallygasters</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/SFM61319/sassy-snallygasters_code-jam-9" title="Sassy Snallygasters">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Silly Selkies</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/kguzek/code-jam-2022" title="Silly Selkies">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Spiffy Sphinxes</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/Sanyok6/Chesstopia" title="Spiffy Sphinxes">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Stately Satyrs</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/Lime-Parallelogram/cj2022-stately-satyrs" title="Stately Satyrs">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Tireless Timingila</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/Ibrahim2750mi/tireless-timingila" title="Tireless Timingila">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Towering Tupilaqs</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/TheFactoryMustGrowLarger/towering-tupilaqs" title="Towering Tupilaqs">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Valiant Valkyries</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/legosiv/valiant-valkyries" title="Valiant Valkyries">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Wily Werewolves</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/HarshitJoshi9152/Codejam-2022-pythondiscord" title="Wily Werewolves">GitHub</a></p> + </div> + </div> + + <div class="columns is-mobile is-centered"> + <div class="column is-half"> + <p class="has-text-centered">Zesty Zombies</p> + </div> + <div class="column is-half"> + <p class="has-text-centered"><a href="https://github.com/nitinramvelraj/zesty-zombies-2.0" title="Zesty Zombies">GitHub</a></p> + </div> + </div> + +{% 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..c7975679 100644 --- a/pydis_site/templates/events/pages/code-jams/_index.html +++ b/pydis_site/templates/events/pages/code-jams/_index.html @@ -31,7 +31,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/pages/game-jams/2020/judging.html b/pydis_site/templates/events/pages/game-jams/2020/judging.html index 1ec836ac..1cbe2679 100644 --- a/pydis_site/templates/events/pages/game-jams/2020/judging.html +++ b/pydis_site/templates/events/pages/game-jams/2020/judging.html @@ -50,7 +50,7 @@ <p>We require that you make the project easy to comprehend, easy to install, and easy to run. To ensure this, your submission must contain a <code>README.md</code> in your team folder which clearly documents how to interact with it. </p> <p> - First of all, you need to include instructions on exactly how to get your game running. We require that you use a <strong>dependency manager</strong> and encourage you to make a <strong>click-and-play option</strong>, and these should be documented in this section. Please see <a href="/events/game-jam-2020/technical-requirements/">our technical requirements page</a> for more information on this. Remember, we have to judge dozens of submissions, so if every submission can be set up and run by just doing <code>pipenv install</code> and then <code>pipenv run start</code>, it will make our job significantly easier. + First of all, you need to include instructions on exactly how to get your game running. We require that you use a <strong>dependency manager</strong> and encourage you to make a <strong>click-and-play option</strong>, and these should be documented in this section. Please see <a href="/events/game-jams/2020/technical-requirements/">our technical requirements page</a> for more information on this. Remember, we have to judge dozens of submissions, so if every submission can be set up and run by just doing <code>pipenv install</code> and then <code>pipenv run start</code>, it will make our job significantly easier. </p> <p> The readme should also contain information about the game, screenshots, a logo if you got it, your team name, instructions on how to play and anything else that helps provide a high-level overview over the game. This is also extremely helpful when we are judging so many projects, so that we can easily see which is which by looking through readmes. For an example of an excellent readme, check out <a href="https://github.com/python-discord/code-jam-6/blob/master/amphibian-alchemists/README.md">this readme created by the Amphibian Alchemists team during Code Jam 6</a>. diff --git a/pydis_site/templates/events/sidebar/code-jams/10.html b/pydis_site/templates/events/sidebar/code-jams/10.html new file mode 100644 index 00000000..31b5bda5 --- /dev/null +++ b/pydis_site/templates/events/sidebar/code-jams/10.html @@ -0,0 +1,8 @@ +{% 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/10/rules" %}">Rules</a> + <a class="panel-block has-text-link" href="{% url "events:page" path="code-jams/10/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> 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..d74470cc 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/10" %}"> + <img src="{% static "images/events/cj10_2023_banner2.png" %}" alt="Code Jam 2023"> </a> </div> diff --git a/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html b/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html index 21b2ccb4..b7875ff6 100644 --- a/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html +++ b/pydis_site/templates/events/sidebar/code-jams/previous-code-jams.html @@ -1,6 +1,8 @@ <div class="box"> <p class="menu-label">Previous Code Jams</p> <ul class="menu-list"> + <li><a class="has-text-link" href="{% url "events:page" path="code-jams/10" %}">Code Jam 10: Secret Codes</a></li> + <li><a class="has-text-link" href="{% url "events:page" path="code-jams/9" %}">Code Jam 9: It's Not A Bug, It's A Feature</a></li> <li><a class="has-text-link" href="{% url "events:page" path="code-jams/8" %}">Code Jam 8: Think Inside the Box</a></li> <li><a class="has-text-link" href="{% url "events:page" path="code-jams/7" %}">Code Jam 7: Early Internet</a></li> <li><a class="has-text-link" href="{% url "events:page" path="code-jams/6" %}">Code Jam 6: Ancient Technology</a></li> diff --git a/pydis_site/templates/events/sidebar/events-list.html b/pydis_site/templates/events/sidebar/events-list.html index b97721be..14034702 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 2023</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="is-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="is-black" style="cursor: default;">December: Advent of Code</a></li> + <li><a class="has-text-black" style="cursor: default;">March: PyWeek 35</a></li> + <li><a class="has-text-black" style="cursor: default;">August/September: Code Jam 2023</a></li> + <li><a class="has-text-black" style="cursor: default;">September: PyWeek 36</a></li> + <li><a class="has-text-black" style="cursor: default;">TBD: Pixels</a></li> + <li><a class="has-text-black" style="cursor: default;">December: Advent of Code</a></li> + </ul> +</div> + +<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..782ef23c 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/10" %}"> + <img src="{% static "images/events/cj10_2023_banner2.png" %}" alt="Code Jam 2023"> </a> </div> diff --git a/pydis_site/templates/home/index.html b/pydis_site/templates/home/index.html index f66c7675..0e592f72 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/cj10_2023_banner3.png" %}" alt="Code Jam 2023"> + </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/10/"> + <img src="{% static "images/events/cj10_2023_banner3.png" %}" alt="Code Jam 2023"> + </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> @@ -174,28 +189,28 @@ </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"/> + <img src="{% static "images/sponsors/netcup.png" %}" alt="netcup" loading="lazy"/> </a> <a href="https://www.linode.com/?r=3bc18ce876ff43ea31f201b91e8e119c9753f085" class="column is-narrow"> - <img src="{% static "images/sponsors/linode.png" %}" alt="Linode"/> + <img src="{% static "images/sponsors/linode.png" %}" alt="Linode" loading="lazy"/> </a> <a href="https://jetbrains.com" class="column is-narrow"> - <img src="{% static "images/sponsors/jetbrains.png" %}" alt="JetBrains"/> + <img src="{% static "images/sponsors/jetbrains.png" %}" alt="JetBrains" loading="lazy"/> </a> <a href="https://sentry.io" class="column is-narrow"> - <img src="{% static "images/sponsors/sentry.png" %}" alt="Sentry"/> + <img src="{% static "images/sponsors/sentry.png" %}" alt="Sentry" loading="lazy"/> </a> <a href="https://notion.so" class="column is-narrow"> - <img src="{% static "images/sponsors/notion.png" %}" alt="Notion"/> + <img src="{% static "images/sponsors/notion.png" %}" alt="Notion" loading="lazy"/> </a> <a href="https://streamyard.com" class="column is-narrow"> - <img src="{% static "images/sponsors/streamyard.png" %}" alt="StreamYard"/> + <img src="{% static "images/sponsors/streamyard.png" %}" alt="StreamYard" loading="lazy"/> </a> <a href="https://www.netlify.com/" class="column is-narrow"> - <img src="{% static "images/sponsors/netlify.png" %}" alt="Netlify"/> + <img src="{% static "images/sponsors/netlify.png" %}" alt="Netlify" loading="lazy"/> </a> <a href="https://www.cloudflare.com/" class="column is-narrow"> - <img src="{% static "images/sponsors/cloudflare.png" %}" alt="Cloudflare"/> + <img src="{% static "images/sponsors/cloudflare.png" %}" alt="Cloudflare" loading="lazy"/> </a> </div> </div> diff --git a/pydis_site/templates/home/timeline.html b/pydis_site/templates/home/timeline.html index 846f4ab2..10c91007 100644 --- a/pydis_site/templates/home/timeline.html +++ b/pydis_site/templates/home/timeline.html @@ -10,130 +10,207 @@ {% block content %} {% include "base/navbar.html" %} - <section class="cd-timeline js-cd-timeline has-background-white"> + <section class="cd-timeline js-cd-timeline"> <div class="container max-width-lg cd-timeline__container"> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-pink cd-timeline__img--picture"> + <i class="fa fa-comments"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Switch to new paste service</h2> + <p class="color-contrast-medium"> + We migrate over to <a href="https://github.com/supakeen/pinnwand">pinnwand</a> + as the service that powers our paste bin over at + <a href="https://paste.pythondiscord.com">https://paste.pythondiscord.com/</a>. + We made the switch as it comes with native light/dark modes, support for multi-file + pastes, additional support for text highlighting languages, and plus, it's written in + Python! + </p> + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Jul 11th, 2023</span> + </div> + </div> + </div> + <div class="cd-timeline__block"> <div class="cd-timeline__img cd-timeline__img--picture"> <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture"> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Python Discord is created</h2> - <p class="color-contrast-medium has-text-dark has-text-dark"><strong>Joe Banks</strong> becomes one of the owners around 3 days after it - is created, and <strong>Leon Sandøy</strong> (lemon) joins the owner team later in the year, when the community - has around 300 members.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Retirement of Joe and Sebastiaan</h2> + <p class="color-contrast-medium"> + Having been at the helm of Python Discord for over 5 and 3 years respectively, Joe and + Sebastiaan retire and step down. They gain the @Founders role and continue as advisors + to the @Directors, the new name of the original @Owners role. + At the same time, Mina and Zig join Leon as co-directors. + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Jan 8th, 2017</span> + <span class="cd-timeline__date">Jan 30th, 2023</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture"> - <i class="fa fa-users"></i> + <div class="cd-timeline__img pastel-pink cd-timeline__img--picture"> + <i class="fa fa-comments"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Python Discord hits 1,000 members</h2> - <p class="color-contrast-medium has-text-dark">Our main source of new users at this point is a post on Reddit that - happens to get very good SEO. We are one of the top 10 search engine hits for the search term - "python discord".</p> - + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Switch to forum-based help system</h2> + <p class="color-contrast-medium"> + We migrate our help system to use a forum channel, retiring our home-grown rotating help + system after 3 years of service and nearly 500,000 help sessions. Forum channels offer + a better experience as members can create their own dedicated thread in a discoverable place. + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Nov 10th, 2017</span> + <span class="cd-timeline__date">Nov 25th, 2022</span> </div> </div> </div> <div class="cd-timeline__block"> <div class="cd-timeline__img cd-timeline__img--picture"> - <img src={% static "images/timeline/cd-icon-picture.svg" %} alt="Picture"> + <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture"> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Our logo is born. Thanks @Aperture!</h2> - <p class="pydis-logo-banner"><img - src="https://raw.githubusercontent.com/python-discord/branding/main/logos/logo_banner/logo_site_banner.svg"> - </p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Python 3.11 Release Stream</h2> + <p class="color-contrast-medium"> + With the Python 3.10 Release Stream being such a success, we brought it back for the + release of Python 3.11. Hosted by Leon, and CPython 3.11 Release Manager, Pablo Galindo, + they were joined by other CPython Core Developers. Together, they discuss the specific + features and the overall development process of the release. + </p> + + <div class="force-aspect-container"> + <iframe class="force-aspect-content" src="https://www.youtube.com/embed/PGZPSWZSkJI" frameborder="0" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" + allowfullscreen></iframe> + </div> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Feb 3rd, 2018</span> + <span class="cd-timeline__date">Oct 24th, 2022</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture"> - <i class="fa fa-users"></i> + <div class="cd-timeline__img pastel-lime cd-timeline__img--picture"> + <i class="fa fa-dice"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">PyDis hits 2,000 members; pythondiscord.com and @Python are live</h2> - <p class="color-contrast-medium has-text-dark">The public moderation bot we're using at the time, Rowboat, announces - it will be shutting down. We decide that we'll write our own bot to handle moderation, so that we - can have more control over its features. We also buy a domain and start making a website in Flask. + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Summer Code Jam 2022 (CJ9)</h2> + <p class="color-contrast-medium"> + We host the 9th Code Jam. This year, teams had to use <b>websockets</b> to create a + project based on the theme, <b>It's not a bug, it's a feature</b>. In all, 24 teams + submitted their projects. At the end, we held a livestream demoing the top 10 projects + and announcing the winner! </p> + <div class="force-aspect-container"> + <iframe class="force-aspect-content" src="https://www.youtube.com/embed/YZcVjFED6Hg" frameborder="0" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" + allowfullscreen></iframe> + </div> + <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Mar 4th, 2018</span> + <span class="cd-timeline__date">July 21st, 2022</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-blue cd-timeline__img--picture"> - <i class="fa fa-dice"></i> + <div style="background-color: grey;" class="cd-timeline__img cd-timeline__img--picture"> + <i class="fa fa-handshake"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">First code jam with the theme “snakes”</h2> - <p class="color-contrast-medium has-text-dark">Our very first Code Jam attracts a handful of users who work in random - teams of 2. We ask our participants to write a snake-themed Discord bot. Most of the code written - for this jam still lives on in Sir Lancebot, and you can play with it by using the - <code>.snakes</code> command. For more information on this event, see <a - href="https://pythondiscord.com/pages/code-jams/code-jam-1-snakes-bot/">the event page</a></p> - + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Partnership with pyqtgraph</h2> + <p class="color-contrast-medium"> + The <code>#pyqtgraph</code> channel is created for the Scientific Graphics and GUI + library pyqtgraph, joining <code>#black-formatter</code>. + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Mar 23rd, 2018</span> + <span class="cd-timeline__date">May 19th, 2022</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-lime cd-timeline__img--picture"> - <i class="fa fa-scroll"></i> + <div class="cd-timeline__img pastel-blue cd-timeline__img--picture"> + <i class="fa fa-robot"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">The privacy policy is created</h2> - <p class="color-contrast-medium has-text-dark">Since data privacy is quite important to us, we create a privacy page - pretty much as soon as our new bot and site starts collecting some data. To this day, we keep <a - href="https://pythondiscord.com/pages/privacy/">our privacy policy</a> up to date with all - changes, and since April 2020 we've started doing <a - href="https://pythondiscord.notion.site/6784e3a9752444e89d19e65fd4510d8d">monthly data reviews</a>.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Addition of @Sir Robin</h2> + <p class="color-contrast-medium"> + Our arsenal of bots grows! We add @Sir Robin to power and manage all of our future + events and to support the Events Team. + </p> + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Feb 21st, 2022</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-green cd-timeline__img--picture"> + <i class="fa fa-question"></i> + </div> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Trivia Night</h2> + <p class="color-contrast-medium"> + How well do you know Python inside out? Members got to find out in a Trivia Night event. + Contestants were given questions about Python's internals, its development, and more. + To win, contestants had to get the most questions right while being fast to answer. + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">May 21st, 2018</span> + <span class="cd-timeline__date">Feb 12th, 2022</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-pink cd-timeline__img--picture"> - <i class="fa fa-handshake"></i> + <div class="cd-timeline__img cd-timeline__img--picture"> + <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture"> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Do You Even Python and PyDis merger</h2> - <p class="color-contrast-medium has-text-dark">At this point in time, there are only two serious Python communities on - Discord - Ours, and one called Do You Even Python. We approach the owners of DYEP with a bold - proposal - let's shut down their community, replace it with links to ours, and in return we will let - their staff join our staff. This gives us a big boost in members, and eventually leads to @eivl and - @Mr. Hemlock joining our Admin team</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Creation of Events Team</h2> + <p class="color-contrast-medium"> + We form the Events Team to organise and run future events. Led by Kat and comprised by + staff members, the goal of the team is to ultimately host more events in a more + sustainable way. + </p> + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Feb 9th, 2022</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-red cd-timeline__img--picture"> + <i class="fa fa-code"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Deployment of Smarter Resources</h2> + <p class="color-contrast-medium"> + We gave our resources pages some much needed love and + <a href="https://www.pythondiscord.com/resources/"> + reorganised them into a single page</a>, + complete with a shiny new resource filter that allows you to more quickly find + ones that relate to your interests, experience, learning style, and ability to pay! + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Jun 9th, 2018</span> + <span class="cd-timeline__date">Feb 2nd, 2022</span> </div> </div> </div> @@ -143,152 +220,216 @@ <i class="fa fa-users"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">PyDis hits 5,000 members and partners with r/Python</h2> - <p class="color-contrast-medium has-text-dark">As we continue to grow, we approach the r/Python subreddit and ask to - become their official Discord community. They agree, and we become listed in their sidebar, giving - us yet another source of new members.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>We hit 300 000 members!</h2> + <p class="color-contrast-medium"> + Thanks to an increasing growth rate, Python Discord's membership count doubled from + 150,000 to 300,000 in less than a year! + </p> + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Jan 19, 2022</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img cd-timeline__img--picture"> + <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture"> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>We host the Python 3.10 Release Stream</h2> + <p class="color-contrast-medium"> + Leon and Pablo Galindo, CPython Core Developer and Release Manager, host the Python 3.10 + Release Stream, joined by other core devs Carol Willing, Irit Katriel, Łukasz Langa, and + Brandt Bucher. They talked in-depth about the new features introduced in 3.10, the + development of those features, and more! + </p> + + <div class="force-aspect-container"> + <iframe class="force-aspect-content" src="https://www.youtube.com/embed/AHT2l3hcIJg" frameborder="0" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" + allowfullscreen></iframe> + </div> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Jun 20th, 2018</span> + <span class="cd-timeline__date">Oct 4th, 2021</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-pink cd-timeline__img--picture"> + <div style="background-color: grey;" class="cd-timeline__img cd-timeline__img--picture"> <i class="fa fa-handshake"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">PyDis is now partnered with Discord; the vanity URL discord.gg/python is created</h2> - <p class="color-contrast-medium has-text-dark">After being rejected for their Partner program several times, we - finally get approved. The recent partnership with the r/Python subreddit plays a significant role in - qualifying us for this partnership.</p> - + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Partnership with Black</h2> + <p class="color-contrast-medium"> + We partner with the uncompromising code formatter project, Black, who were looking for a + new home for their real-time chat. Python Discord ended up being that home, resulting in + the creation of the <code>#black-formatter</code> channel. + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Jul 10th, 2018</span> + <span class="cd-timeline__date">May 24th, 2021</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-blue cd-timeline__img--picture"> + <div class="cd-timeline__img pastel-orange cd-timeline__img--picture"> <i class="fa fa-dice"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">First Hacktoberfest PyDis event; @Sir Lancebot is created</h2> - <p class="color-contrast-medium has-text-dark">We create a second bot for our community and fill it up with simple, - fun and relatively easy issues. The idea is to create an approachable arena for our members to cut - their open-source teeth on, and to provide lots of help and hand-holding for those who get stuck. - We're training our members to be productive contributors in the open-source ecosystem.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Summer Code Jam 2021 (CJ8)</h2> + <p class="color-contrast-medium"> + We host the 8th now-annual Code Jam. Teams had to create a program with an text-based user + interface (TUI), all designed around the theme of “think inside the box.” Ultimately, 51 + teams submitted projects. + <a href="https://www.pythondiscord.com/events/code-jams/8/"> + The winning submissions are listed on our website. + </a> + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Oct 1st, 2018</span> + <span class="cd-timeline__date">July 9, 2021</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture"> - <i class="fa fa-users"></i> + <div class="cd-timeline__img pastel-purple cd-timeline__img--picture"> + <i class="fa fa-palette"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">PyDis hits 10,000 members</h2> - <p class="color-contrast-medium has-text-dark">We partner with RLBot, move from GitLab to GitHub, and start putting - together the first Advent of Code event.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Inaugural run of Pixels</h2> + <p class="color-contrast-medium"> + Inspired by the subreddit, r/place, Pixels was our collaborative canvas event held + between May 25 to June 14, providing a beginner-friendly API to paint pixels on a + virtual canvas. + </p> + <p class="color-contrast-medium"> + Later, we released <a href="https://blog.pythondiscord.com/pixels-summer-2021/">a blog post</a> + summarizing what happened, our motives, and some stories from during development. + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Nov 24th, 2018</span> + <span class="cd-timeline__date">May 24, 2021</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-orange cd-timeline__img--picture"> - <i class="fa fa-code"></i> + <div class="cd-timeline__img cd-timeline__img--picture"> + <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture"> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">django-simple-bulma is released on PyPi</h2> - <p class="color-contrast-medium has-text-dark">Our very first package on PyPI, <a - href="https://pypi.org/project/django-simple-bulma/">django-simple-bulma</a> is a package that - sets up the Bulma CSS framework for your Django application and lets you configure everything in - settings.py.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Owners become PSF Fellows</h2> + <p class="color-contrast-medium">Joe, Leon, and Sebastiaan + <a href="https://pyfound.blogspot.com/2021/04/python-software-foundation-fellow.html"> + are recognized as Python Software Foundation Fellows + </a> + for their substantial contributions to the Python ecosystem by fostering + Python Discord. + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Dec 19th, 2018</span> + <span class="cd-timeline__date">April 23, 2021</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture"> - <i class="fa fa-users"></i> + <div class="cd-timeline__img pastel-red cd-timeline__img--picture"> + <i class="fa fa-youtube-play"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">PyDis hits 15,000 members; the “hot ones special” video is released</h2> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Summer Code Jam 2020 Highlights</h2> + <p class="color-contrast-medium"> + We release a new video to our YouTube showing the best projects from the Summer Code Jam 2020. + Better late than never! + </p> + <div class="force-aspect-container"> - <iframe class="force-aspect-content" src="https://www.youtube.com/embed/DIBXg8Qh7bA" frameborder="0" + <iframe class="force-aspect-content" src="https://www.youtube.com/embed/g9cnp4W0P54" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> </div> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Apr 8th, 2019</span> + <span class="cd-timeline__date">Mar 21st, 2021</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-orange cd-timeline__img--picture"> - <i class="fa fa-code"></i> + <div class="cd-timeline__img pastel-purple cd-timeline__img--picture"> + <i class="fa fa-comment"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">The Django rewrite of pythondiscord.com is now live!</h2> - <p class="color-contrast-medium has-text-dark">The site is getting more and more complex, and it's time for a rewrite. - We decide to go for a different stack, and build a website based on Django, DRF, Bulma and - PostgreSQL.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>New feature: Weekly discussion channel</h2> + <p class="color-contrast-medium">Every week (or two weeks), we'll be posting a new topic to discuss in a + channel called <b>#weekly-topic-discussion</b>. Our inaugural topic is a PyCon talk by Anthony Shaw called + <b>Wily Python: Writing simpler and more maintainable Python.</b></a>. + </p> + + <div class="force-aspect-container"> + <iframe class="force-aspect-content" src="https://www.youtube.com/embed/dqdsNoApJ80" frameborder="0" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" + allowfullscreen></iframe> + </div> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Sep 15, 2019</span> + <span class="cd-timeline__date">Mar 13th, 2021</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-lime cd-timeline__img--picture"> - <i class="fa fa-scroll"></i> + <div class="cd-timeline__img pastel-pink cd-timeline__img--picture"> + <i class="fa fa-microphone"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">The code of conduct is created</h2> - <p class="color-contrast-medium has-text-dark">Inspired by the Adafruit, Rust and Django communities, an essential - community pillar is created; Our <a href="https://pythondiscord.com/pages/code-of-conduct/">Code of - Conduct.</a></p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>We're on the Teaching Python podcast!</h2> + <p class="color-contrast-medium">Leon joins Sean and Kelly on the Teaching Python podcast to discuss how the pandemic has + changed the way we learn, and what role communities like Python Discord can play in this new world. + You can find the episode <a href="https://www.teachingpython.fm/63">at teachingpython.fm</a>. + </p> + + <iframe width="100%" height="166" frameborder="0" scrolling="no" + src="https://player.fireside.fm/v2/UIYXtbeL+qOjGAsKi?theme=dark" + ></iframe> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Oct 26th, 2019</span> + <span class="cd-timeline__date">Mar 13th, 2021</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img cd-timeline__img--picture"> - <img src={% static "images/timeline/cd-icon-picture.svg" %} alt="Picture"> + <div class="cd-timeline__img pastel-green cd-timeline__img--picture"> + <i class="fa fa-microphone"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Sebastiaan Zeef becomes an owner</h2> - <p class="color-contrast-medium has-text-dark">After being a long time active contributor to our projects and the driving - force behind many of our events, Sebastiaan Zeef joins the Owners Team alongside Joe & Leon.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Leon Sandøy appears on Talk Python To Me</h2> + <p class="color-contrast-medium">Leon goes on the Talk Python to Me podcast with Michael Kennedy + to discuss the history of Python Discord, the critical importance of culture, and how to run a massive + community. You can find the episode <a href="https://talkpython.fm/episodes/show/305/python-community-at-python-discord"> at talkpython.fm</a>. + </p> + + <iframe width="100%" height="166" scrolling="no" frameborder="no" + src="https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/996083146&color=ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false"> + </iframe> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Sept 22nd, 2019</span> + <span class="cd-timeline__date">Mar 1st, 2021</span> </div> </div> </div> @@ -298,54 +439,73 @@ <i class="fa fa-users"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">PyDis hits 30,000 members</h2> - <p class="color-contrast-medium has-text-dark">More than tripling in size since the year before, the community hits - 30000 users. At this point, we're probably the largest Python chat community on the planet.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>We now have 150,000 members!</h2> + <p class="color-contrast-medium">Our growth continues to accelerate.</p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Dec 22nd, 2019</span> + <span class="cd-timeline__date">Feb 18th, 2021</span> </div> </div> </div> <div class="cd-timeline__block"> <div class="cd-timeline__img pastel-blue cd-timeline__img--picture"> - <i class="fa fa-dice"></i> + <i class="fa fa-music"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">PyDis sixth code jam with the theme “Ancient technology” and the technology Kivy</h2> - <p class="color-contrast-medium has-text-dark">Our Code Jams are becoming an increasingly big deal, and the Kivy core - developers join us to judge the event and help out our members during the event. One of them, - @tshirtman, even joins our staff!</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>We release The PEP 8 song</h2> + <p class="color-contrast-medium">We release the PEP 8 song on our YouTube channel, which finds tens of + thousands of listeners!</p> <div class="force-aspect-container"> - <iframe class="force-aspect-content" src="https://www.youtube.com/embed/8fbZsGrqBzo" frameborder="0" + <iframe class="force-aspect-content" src="https://www.youtube.com/embed/hgI0p1zf31k" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> </div> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Jan 17, 2020</span> + <span class="cd-timeline__date">February 8th, 2021</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-green cd-timeline__img--picture"> - <i class="fa fa-comments"></i> + <div class="cd-timeline__img pastel-red cd-timeline__img--picture"> + <i class="fa fa-snowflake-o"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">The new help channel system is live</h2> - <p class="color-contrast-medium has-text-dark">We release our dynamic help-channel system, which allows you to claim - your very own help channel instead of fighting over the static help channels. We release a <a - href="https://pythondiscord.com/pages/resources/guides/help-channels/">Help Channel Guide</a> to - help our members fully understand how the system works.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Advent of Code attracts hundreds of participants</h2> + <p class="color-contrast-medium"> + A total of 443 Python Discord members sign up to be part of + <a href="https://adventofcode.com/">Eric Wastl's excellent Advent of Code event</a>. + As always, we provide dedicated announcements, scoreboards, bot commands and channels for our members + to enjoy the event in. + + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Apr 5th, 2020</span> + <span class="cd-timeline__date">December 1st - 25th, 2020</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-orange cd-timeline__img--picture"> + <i class="fa fa-wrench"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>We migrate all our infrastructure to Kubernetes</h2> + <p class="color-contrast-medium">As our tech stack grows, we decide to migrate all our services over to a + container orchestration paradigm via Kubernetes. This gives us better control and scalability. + <b>Joe Banks</b> takes on the role as DevOps Lead. + </p> + + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Nov 29th, 2020</span> </div> </div> </div> @@ -355,54 +515,90 @@ <i class="fa fa-users"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Python Discord hits 40,000 members, and is now bigger than Liechtenstein.</h2> - <p class="color-contrast-medium has-text-dark"><img - src="https://cdn.discordapp.com/attachments/354619224620138496/699666518476324954/unknown.png"> - </p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Python Discord hits 100,000 members!</h2> + <p class="color-contrast-medium">Only six months after hitting 40,000 users, we hit 100,000 users. A + monumental milestone, + and one we're very proud of. To commemorate it, we create this timeline.</p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Apr 14, 2020</span> + <span class="cd-timeline__date">Oct 22nd, 2020</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-purple cd-timeline__img--picture"> - <i class="fa fa-gamepad"></i> + <div class="cd-timeline__img cd-timeline__img--picture"> + <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture"> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">PyDis Game Jam 2020 with the “Three of a Kind” theme and Arcade as the technology</h2> - <p class="color-contrast-medium has-text-dark">The creator of Arcade, Paul Vincent Craven, joins us as a judge. - Several of the Code Jam participants also end up getting involved contributing to the Arcade - repository.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Python Discord hosts the 2020 CPython Core Developer Q&A</h2> + <div class="force-aspect-container"> + <iframe class="force-aspect-content" src="https://www.youtube.com/embed/gXMdfBTcOfQ" frameborder="0" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" + allowfullscreen></iframe> + </div> + + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Oct 21st, 2020</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-pink cd-timeline__img--picture"> + <i class="fa fa-handshake"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Python Discord is now the new home of the PyWeek event!</h2> + <p class="color-contrast-medium">PyWeek, a game jam that has been running since 2005, joins Python + Discord as one of our official events. Find more information about PyWeek on <a + href="https://pyweek.org/">their official website</a>.</p> + + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Aug 16th, 2020</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-blue cd-timeline__img--picture"> + <i class="fa fa-dice"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>PyDis summer code jam 2020 with the theme “Early Internet” and Django as the technology</h2> + <p class="color-contrast-medium">Sponsored by the Django Software Foundation and JetBrains, the Summer + Code Jam for 2020 attracts hundreds of participants, and sees the creation of some fantastic + projects. Check them out in our judge stream below:</p> <div class="force-aspect-container"> - <iframe class="force-aspect-content" src="https://www.youtube.com/embed/KkLXMvKfEgs" frameborder="0" + <iframe class="force-aspect-content" src="https://www.youtube.com/embed/OFtm8f2iu6c" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> </div> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Apr 17th, 2020</span> + <span class="cd-timeline__date">Jul 31st, 2020</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-green cd-timeline__img--picture"> - <i class="fa fa-comments"></i> + <div class="cd-timeline__img pastel-red cd-timeline__img--picture"> + <i class="fa fa-chart-bar"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">ModMail is now live</h2> - <p class="color-contrast-medium has-text-dark">Having originally planned to write our own ModMail bot from scratch, we - come across an exceptionally good <a href="https://github.com/kyb3r/modmail">ModMail bot by - kyb3r</a> and decide to just self-host that one instead.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Python Discord Public Statistics are now live</h2> + <p class="color-contrast-medium">After getting numerous requests to publish beautiful data on member + count and channel use, we create <a href="https://stats.pythondiscord.com/">stats.pythondiscord.com</a> for + all to enjoy.</p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">May 25th, 2020</span> + <span class="cd-timeline__date">Jun 4th, 2020</span> </div> </div> </div> @@ -412,9 +608,9 @@ <i class="fa fa-handshake"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Python Discord is now listed on python.org/community</h2> - <p class="color-contrast-medium has-text-dark">After working towards this goal for months, we finally work out an + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Python Discord is now listed on python.org/community</h2> + <p class="color-contrast-medium">After working towards this goal for months, we finally work out an arrangement with the PSF that allows us to be listed on that most holiest of websites: https://python.org/. <a href="https://youtu.be/yciX2meIkXI?t=3">There was much rejoicing.</a></p> @@ -425,77 +621,99 @@ </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-red cd-timeline__img--picture"> - <i class="fa fa-chart-bar"></i> + <div class="cd-timeline__img pastel-green cd-timeline__img--picture"> + <i class="fa fa-comments"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Python Discord Public Statistics are now live</h2> - <p class="color-contrast-medium has-text-dark">After getting numerous requests to publish beautiful data on member - count and channel use, we create <a href="https://stats.pythondiscord.com/">stats.pythondiscord.com</a> for - all to enjoy.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>ModMail is now live</h2> + <p class="color-contrast-medium">Having originally planned to write our own ModMail bot from scratch, we + come across an exceptionally good <a href="https://github.com/kyb3r/modmail">ModMail bot by + kyb3r</a> and decide to just self-host that one instead.</p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Jun 4th, 2020</span> + <span class="cd-timeline__date">May 25th, 2020</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-blue cd-timeline__img--picture"> - <i class="fa fa-dice"></i> + <div class="cd-timeline__img pastel-purple cd-timeline__img--picture"> + <i class="fa fa-gamepad"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">PyDis summer code jam 2020 with the theme “Early Internet” and Django as the technology</h2> - <p class="color-contrast-medium has-text-dark">Sponsored by the Django Software Foundation and JetBrains, the Summer - Code Jam for 2020 attracts hundreds of participants, and sees the creation of some fantastic - projects. Check them out in our judge stream below:</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>PyDis Game Jam 2020 with the “Three of a Kind” theme and Arcade as the technology</h2> + <p class="color-contrast-medium">The creator of Arcade, Paul Vincent Craven, joins us as a judge. + Several of the Code Jam participants also end up getting involved contributing to the Arcade + repository.</p> <div class="force-aspect-container"> - <iframe class="force-aspect-content" src="https://www.youtube.com/embed/OFtm8f2iu6c" frameborder="0" + <iframe class="force-aspect-content" src="https://www.youtube.com/embed/KkLXMvKfEgs" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> </div> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Jul 31st, 2020</span> + <span class="cd-timeline__date">Apr 17th, 2020</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-pink cd-timeline__img--picture"> - <i class="fa fa-handshake"></i> + <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture"> + <i class="fa fa-users"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Python Discord is now the new home of the PyWeek event!</h2> - <p class="color-contrast-medium has-text-dark">PyWeek, a game jam that has been running since 2005, joins Python - Discord as one of our official events. Find more information about PyWeek on <a - href="https://pyweek.org/">their official website</a>.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Python Discord hits 40,000 members, and is now bigger than Liechtenstein.</h2> + <p class="color-contrast-medium"><img + src="https://cdn.discordapp.com/attachments/354619224620138496/699666518476324954/unknown.png"> + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Aug 16th, 2020</span> + <span class="cd-timeline__date">Apr 14, 2020</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img cd-timeline__img--picture"> - <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture"> + <div class="cd-timeline__img pastel-green cd-timeline__img--picture"> + <i class="fa fa-comments"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Python Discord hosts the 2020 CPython Core Developer Q&A</h2> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>The new help channel system is live</h2> + <p class="color-contrast-medium">We release our dynamic help-channel system, which allows you to claim + your very own help channel instead of fighting over the static help channels. We release a <a + href="https://pythondiscord.com/pages/resources/guides/help-channels/">Help Channel Guide</a> to + help our members fully understand how the system works.</p> + + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Apr 5th, 2020</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-blue cd-timeline__img--picture"> + <i class="fa fa-dice"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>PyDis sixth code jam with the theme “Ancient technology” and the technology Kivy</h2> + <p class="color-contrast-medium">Our Code Jams are becoming an increasingly big deal, and the Kivy core + developers join us to judge the event and help out our members during the event. One of them, + @tshirtman, even joins our staff!</p> + <div class="force-aspect-container"> - <iframe class="force-aspect-content" src="https://www.youtube.com/embed/gXMdfBTcOfQ" frameborder="0" + <iframe class="force-aspect-content" src="https://www.youtube.com/embed/8fbZsGrqBzo" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> </div> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Oct 21st, 2020</span> + <span class="cd-timeline__date">Jan 17, 2020</span> </div> </div> </div> @@ -505,76 +723,100 @@ <i class="fa fa-users"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Python Discord hits 100,000 members!</h2> - <p class="color-contrast-medium has-text-dark">Only six months after hitting 40,000 users, we hit 100,000 users. A - monumental milestone, - and one we're very proud of. To commemorate it, we create this timeline.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>PyDis hits 30,000 members</h2> + <p class="color-contrast-medium">More than tripling in size since the year before, the community hits + 30000 users. At this point, we're probably the largest Python chat community on the planet.</p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Oct 22nd, 2020</span> + <span class="cd-timeline__date">Dec 22nd, 2019</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-orange cd-timeline__img--picture"> - <i class="fa fa-wrench"></i> + <div class="cd-timeline__img cd-timeline__img--picture"> + <img src={% static "images/timeline/cd-icon-picture.svg" %} alt="Picture"> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">We migrate all our infrastructure to Kubernetes</h2> - <p class="color-contrast-medium has-text-dark">As our tech stack grows, we decide to migrate all our services over to a - container orchestration paradigm via Kubernetes. This gives us better control and scalability. - <b>Joe Banks</b> takes on the role as DevOps Lead. - </p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Sebastiaan Zeef becomes an owner</h2> + <p class="color-contrast-medium">After being a long time active contributor to our projects and the driving + force behind many of our events, Sebastiaan Zeef joins the Owners Team alongside Joe & Leon.</p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Nov 29th, 2020</span> + <span class="cd-timeline__date">Sept 22nd, 2019</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-red cd-timeline__img--picture"> - <i class="fa fa-snowflake-o"></i> + <div class="cd-timeline__img pastel-lime cd-timeline__img--picture"> + <i class="fa fa-scroll"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Advent of Code attracts hundreds of participants</h2> - <p class="color-contrast-medium has-text-dark"> - A total of 443 Python Discord members sign up to be part of - <a href="https://adventofcode.com/">Eric Wastl's excellent Advent of Code event</a>. - As always, we provide dedicated announcements, scoreboards, bot commands and channels for our members - to enjoy the event in. - - </p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>The code of conduct is created</h2> + <p class="color-contrast-medium">Inspired by the Adafruit, Rust and Django communities, an essential + community pillar is created; Our <a href="https://pythondiscord.com/pages/code-of-conduct/">Code of + Conduct.</a></p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">December 1st - 25th, 2020</span> + <span class="cd-timeline__date">Oct 26th, 2019</span> </div> </div> </div> - <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-blue cd-timeline__img--picture"> - <i class="fa fa-music"></i> + <div class="cd-timeline__img pastel-orange cd-timeline__img--picture"> + <i class="fa fa-code"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">We release The PEP 8 song</h2> - <p class="color-contrast-medium has-text-dark">We release the PEP 8 song on our YouTube channel, which finds tens of - thousands of listeners!</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>The Django rewrite of pythondiscord.com is now live!</h2> + <p class="color-contrast-medium">The site is getting more and more complex, and it's time for a rewrite. + We decide to go for a different stack, and build a website based on Django, DRF, Bulma and + PostgreSQL.</p> + + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Sep 15, 2019</span> + </div> + </div> + </div> + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture"> + <i class="fa fa-users"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>PyDis hits 15,000 members; the “hot ones special” video is released</h2> <div class="force-aspect-container"> - <iframe class="force-aspect-content" src="https://www.youtube.com/embed/hgI0p1zf31k" frameborder="0" + <iframe class="force-aspect-content" src="https://www.youtube.com/embed/DIBXg8Qh7bA" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> </div> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">February 8th, 2021</span> + <span class="cd-timeline__date">Apr 8th, 2019</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-orange cd-timeline__img--picture"> + <i class="fa fa-code"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>django-simple-bulma is released on PyPi</h2> + <p class="color-contrast-medium">Our very first package on PyPI, <a + href="https://pypi.org/project/django-simple-bulma/">django-simple-bulma</a> is a package that + sets up the Bulma CSS framework for your Django application and lets you configure everything in + settings.py.</p> + + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Dec 19th, 2018</span> </div> </div> </div> @@ -584,104 +826,191 @@ <i class="fa fa-users"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">We now have 150,000 members!</h2> - <p class="color-contrast-medium has-text-dark">Our growth continues to accelerate.</p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>PyDis hits 10,000 members</h2> + <p class="color-contrast-medium">We partner with RLBot, move from GitLab to GitHub, and start putting + together the first Advent of Code event.</p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Feb 18th, 2021</span> + <span class="cd-timeline__date">Nov 24th, 2018</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-green cd-timeline__img--picture"> - <i class="fa fa-microphone"></i> + <div class="cd-timeline__img pastel-blue cd-timeline__img--picture"> + <i class="fa fa-dice"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Leon Sandøy appears on Talk Python To Me</h2> - <p class="color-contrast-medium has-text-dark">Leon goes on the Talk Python to Me podcast with Michael Kennedy - to discuss the history of Python Discord, the critical importance of culture, and how to run a massive - community. You can find the episode <a href="https://talkpython.fm/episodes/show/305/python-community-at-python-discord"> at talkpython.fm</a>. - </p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>First Hacktoberfest PyDis event; @Sir Lancebot is created</h2> + <p class="color-contrast-medium">We create a second bot for our community and fill it up with simple, + fun and relatively easy issues. The idea is to create an approachable arena for our members to cut + their open-source teeth on, and to provide lots of help and hand-holding for those who get stuck. + We're training our members to be productive contributors in the open-source ecosystem.</p> - <iframe width="100%" height="166" scrolling="no" frameborder="no" - src="https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/996083146&color=ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false"> - </iframe> + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Oct 1st, 2018</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-pink cd-timeline__img--picture"> + <i class="fa fa-handshake"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>PyDis is now partnered with Discord; the vanity URL discord.gg/python is created</h2> + <p class="color-contrast-medium">After being rejected for their Partner program several times, we + finally get approved. The recent partnership with the r/Python subreddit plays a significant role in + qualifying us for this partnership.</p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Mar 1st, 2021</span> + <span class="cd-timeline__date">Jul 10th, 2018</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture"> + <i class="fa fa-users"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>PyDis hits 5,000 members and partners with r/Python</h2> + <p class="color-contrast-medium">As we continue to grow, we approach the r/Python subreddit and ask to + become their official Discord community. They agree, and we become listed in their sidebar, giving + us yet another source of new members.</p> + + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Jun 20th, 2018</span> </div> </div> </div> <div class="cd-timeline__block"> <div class="cd-timeline__img pastel-pink cd-timeline__img--picture"> - <i class="fa fa-microphone"></i> + <i class="fa fa-handshake"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">We're on the Teaching Python podcast!</h2> - <p class="color-contrast-medium has-text-dark">Leon joins Sean and Kelly on the Teaching Python podcast to discuss how the pandemic has - changed the way we learn, and what role communities like Python Discord can play in this new world. - You can find the episode <a href="https://teachingpython.fm/63"> at teachingpython.fm</a>. - </p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Do You Even Python and PyDis merger</h2> + <p class="color-contrast-medium">At this point in time, there are only two serious Python communities on + Discord - Ours, and one called Do You Even Python. We approach the owners of DYEP with a bold + proposal - let's shut down their community, replace it with links to ours, and in return we will let + their staff join our staff. This gives us a big boost in members, and eventually leads to @eivl and + @Mr. Hemlock joining our Admin team</p> - <iframe width="100%" height="166" frameborder="0" scrolling="no" - src="https://player.fireside.fm/v2/UIYXtbeL+qOjGAsKi?theme=dark" - ></iframe> + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Jun 9th, 2018</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-lime cd-timeline__img--picture"> + <i class="fa fa-scroll"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>The privacy policy is created</h2> + <p class="color-contrast-medium">Since data privacy is quite important to us, we create a privacy page + pretty much as soon as our new bot and site starts collecting some data. To this day, we keep <a + href="https://pythondiscord.com/pages/privacy/">our privacy policy</a> up to date with all + changes, and since April 2020 we've started doing <a + href="https://pythondiscord.notion.site/6784e3a9752444e89d19e65fd4510d8d">monthly data reviews</a>.</p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Mar 13th, 2021</span> + <span class="cd-timeline__date">May 21st, 2018</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-purple cd-timeline__img--picture"> - <i class="fa fa-comment"></i> + <div class="cd-timeline__img pastel-blue cd-timeline__img--picture"> + <i class="fa fa-dice"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">New feature: Weekly discussion channel</h2> - <p class="color-contrast-medium has-text-dark">Every week (or two weeks), we'll be posting a new topic to discuss in a - channel called <b>#weekly-topic-discussion</b>. Our inaugural topic is a PyCon talk by Anthony Shaw called - <b>Wily Python: Writing simpler and more maintainable Python.</b></a>. + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>First code jam with the theme “snakes”</h2> + <p class="color-contrast-medium">Our very first Code Jam attracts a handful of users who work in random + teams of 2. We ask our participants to write a snake-themed Discord bot. Most of the code written + for this jam still lives on in Sir Lancebot, and you can play with it by using the + <code>.snakes</code> command. For more information on this event, see <a + href="https://pythondiscord.com/pages/code-jams/code-jam-1-snakes-bot/">the event page</a></p> + + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Mar 23rd, 2018</span> + </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture"> + <i class="fa fa-users"></i> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>PyDis hits 2,000 members; pythondiscord.com and @Python are live</h2> + <p class="color-contrast-medium">The public moderation bot we're using at the time, Rowboat, announces + it will be shutting down. We decide that we'll write our own bot to handle moderation, so that we + can have more control over its features. We also buy a domain and start making a website in Flask. </p> - <div class="force-aspect-container"> - <iframe class="force-aspect-content" src="https://www.youtube.com/embed/dqdsNoApJ80" frameborder="0" - allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" - allowfullscreen></iframe> + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Mar 4th, 2018</span> </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img cd-timeline__img--picture"> + <img src={% static "images/timeline/cd-icon-picture.svg" %} alt="Picture"> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Our logo is born. Thanks @Aperture!</h2> + <p class="pydis-logo-banner"><img + src="https://raw.githubusercontent.com/python-discord/branding/main/logos/logo_banner/logo_site_banner.svg"> + </p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Mar 13th, 2021</span> + <span class="cd-timeline__date">Feb 3rd, 2018</span> </div> </div> </div> <div class="cd-timeline__block"> - <div class="cd-timeline__img pastel-red cd-timeline__img--picture"> - <i class="fa fa-youtube-play"></i> + <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture"> + <i class="fa fa-users"></i> </div> - <div class="cd-timeline__content text-component has-background-white-bis"> - <h2 class="has-text-dark">Summer Code Jam 2020 Highlights</h2> - <p class="color-contrast-medium has-text-dark"> - We release a new video to our YouTube showing the best projects from the Summer Code Jam 2020. - Better late than never! - </p> + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Python Discord hits 1,000 members</h2> + <p class="color-contrast-medium">Our main source of new users at this point is a post on Reddit that + happens to get very good SEO. We are one of the top 10 search engine hits for the search term + "python discord".</p> - <div class="force-aspect-container"> - <iframe class="force-aspect-content" src="https://www.youtube.com/embed/g9cnp4W0P54" frameborder="0" - allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" - allowfullscreen></iframe> + <div class="flex justify-between items-center"> + <span class="cd-timeline__date">Nov 10th, 2017</span> </div> + </div> + </div> + + <div class="cd-timeline__block"> + <div class="cd-timeline__img cd-timeline__img--picture"> + <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture"> + </div> + + <div class="cd-timeline__content has-background-white-bis text-component"> + <h2>Python Discord is created</h2> + <p class="color-contrast-medium"><strong>Joe Banks</strong> becomes one of the owners around 3 days after it + is created, and <strong>Leon Sandøy</strong> (lemon) joins the owner team later in the year, when the community + has around 300 members.</p> <div class="flex justify-between items-center"> - <span class="cd-timeline__date">Mar 21st, 2021</span> + <span class="cd-timeline__date">Jan 8th, 2017</span> </div> </div> </div> diff --git a/pydis_site/templates/resources/resource_box.html b/pydis_site/templates/resources/resource_box.html index b86947b9..b082c460 100644 --- a/pydis_site/templates/resources/resource_box.html +++ b/pydis_site/templates/resources/resource_box.html @@ -2,7 +2,7 @@ {% load to_kebabcase %} {% load get_category_icon %} -<div class="box resource-box has-background-white-bis {{ resource.css_classes }}"> +<div class="box resource-box has-background-white-bis {{ resource.css_classes }}" data-resource-name="{{ resource.name }}"> {% if 'title_url' in resource %} <a href="{{ resource.title_url }}"> {% include "resources/resource_box_header.html" %} diff --git a/pydis_site/templates/resources/resource_box_header.html b/pydis_site/templates/resources/resource_box_header.html index 84e1a79b..dfbdd92f 100644 --- a/pydis_site/templates/resources/resource_box_header.html +++ b/pydis_site/templates/resources/resource_box_header.html @@ -17,8 +17,7 @@ <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 9c76bc8b..c728b6b4 100644 --- a/pydis_site/templates/resources/resources.html +++ b/pydis_site/templates/resources/resources.html @@ -16,6 +16,7 @@ <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 %} @@ -27,18 +28,33 @@ <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> + <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 is-white has-background-white-bis"> <div class="is-flex ml-auto"> - <div> + <div id="tag-pool"> {# A filter tag for when there are no filters active #} - <span class="no-tags-selected tag has-background-disabled has-text-disabled ml-2 mt-2 has-background-white-ter"> - <i class="fas fa-ban mr-1"></i> + <span class="tag no-tags-selected is-secondary ml-2 mt-2 has-background-white-ter"> + <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" %} @@ -152,7 +168,7 @@ {# Resource cards #} <div class="content is-flex is-justify-content-center"> - <div> + <div class="container is-fullwidth"> {% for resource in resources.values %} {% include "resources/resource_box.html" %} {% endfor %} 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/urls.py b/pydis_site/urls.py index 6cd31f26..0f2f6aeb 100644 --- a/pydis_site/urls.py +++ b/pydis_site/urls.py @@ -12,7 +12,7 @@ NON_STATIC_PATTERNS = [ path('pydis-api/', include('pydis_site.apps.api.urls', namespace='internal_api')), path('', include('django_prometheus.urls')), -] if not settings.env("STATIC_BUILD") else [] +] if not settings.STATIC_BUILD else [] urlpatterns = ( @@ -29,7 +29,7 @@ urlpatterns = ( ) -if not settings.env("STATIC_BUILD"): +if not settings.STATIC_BUILD: urlpatterns += ( path('staff/', include('pydis_site.apps.staff.urls', namespace='staff')), ) 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 |