aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar camcaswell <[email protected]>2022-03-20 17:24:26 -0400
committerGravatar GitHub <[email protected]>2022-03-20 17:24:26 -0400
commit0460bfe4c3f6f26c7447dc96ee4e61900a1e5b27 (patch)
tree3eb8c2efa38aca0294bc079684bad1539ff2ba6b
parentWrite walkthrough (diff)
parentMerge pull request #694 from camcaswell/role-update (diff)
Merge branch 'python-discord:main' into contrib-streamline
-rw-r--r--.github/PULL_REQUEST_TEMPLATE/pull_request.md9
-rw-r--r--pydis_site/README.md68
-rw-r--r--pydis_site/apps/admin/__init__.py0
-rw-r--r--pydis_site/apps/admin/urls.py8
-rw-r--r--pydis_site/apps/api/README.md71
-rw-r--r--pydis_site/apps/api/migrations/0080_add_aoc_tables.py32
-rw-r--r--pydis_site/apps/api/models/__init__.py2
-rw-r--r--pydis_site/apps/api/models/bot/__init__.py2
-rw-r--r--pydis_site/apps/api/models/bot/aoc_completionist_block.py26
-rw-r--r--pydis_site/apps/api/models/bot/aoc_link.py21
-rw-r--r--pydis_site/apps/api/serializers.py22
-rw-r--r--pydis_site/apps/api/tests/test_infractions.py4
-rw-r--r--pydis_site/apps/api/tests/test_models.py7
-rw-r--r--pydis_site/apps/api/tests/test_reminders.py17
-rw-r--r--pydis_site/apps/api/urls.py10
-rw-r--r--pydis_site/apps/api/viewsets/__init__.py2
-rw-r--r--pydis_site/apps/api/viewsets/bot/__init__.py2
-rw-r--r--pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py73
-rw-r--r--pydis_site/apps/api/viewsets/bot/aoc_link.py71
-rw-r--r--pydis_site/apps/api/viewsets/bot/infraction.py17
-rw-r--r--pydis_site/apps/content/README.md32
-rw-r--r--pydis_site/apps/content/migrations/__init__.py0
-rw-r--r--pydis_site/apps/content/resources/server-info/roles.md6
-rw-r--r--pydis_site/apps/home/tests/test_repodata_helpers.py5
-rw-r--r--pydis_site/apps/home/views/home.py7
-rw-r--r--pydis_site/constants.py6
-rw-r--r--pydis_site/context_processors.py5
-rw-r--r--pydis_site/settings.py27
-rw-r--r--pydis_site/templates/staff/logs.html8
-rw-r--r--pydis_site/utils/resources.py91
30 files changed, 503 insertions, 148 deletions
diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request.md b/.github/PULL_REQUEST_TEMPLATE/pull_request.md
index 358d2553..a8bba3e4 100644
--- a/.github/PULL_REQUEST_TEMPLATE/pull_request.md
+++ b/.github/PULL_REQUEST_TEMPLATE/pull_request.md
@@ -11,12 +11,3 @@
- [ ] Joined the [Python Discord community](discord.gg/python)
- [ ] Read the [Code of Conduct](https://www.pydis.com/pages/code-of-conduct) and agree to it
- [ ] I have discussed implementing this feature on the relevant service (Discord, GitHub, etc.)
-
-
-### I have changed API models and I ensure I have:
-<!-- Please remove this section if you haven't edited files under pydis_site/apps/api/models -->
-- [ ] Opened a PR updating the model on the [API GitHub Repository](https://github.com/python-discord/api)
-
-**OR**
-
-- [ ] Opened an issue on the [API GitHub Repository](https://github.com/python-discord/api) explaining what changes need to be made
diff --git a/pydis_site/README.md b/pydis_site/README.md
new file mode 100644
index 00000000..db402743
--- /dev/null
+++ b/pydis_site/README.md
@@ -0,0 +1,68 @@
+# `pydis_site` project directory
+
+This directory hosts the root of our **Django project**[^1], and is responsible
+for all logic powering our website. Let's go over the directories in detail:
+
+- [`apps`](./apps) contains our **Django apps**, which are the building blocks
+ that make up our Django project. A Django project must always consist of one
+ or more apps, and these apps can be made completely modular and reusable
+ across any Django project. In our project, each app controls a distinct part
+ of our website, such as the API or our resources system.
+
+ For more information on reusable apps, see the official Django tutorial,
+ [which has a section on reusable
+ apps](https://docs.djangoproject.com/en/dev/intro/reusable-apps/). To learn
+ more about our specific apps, see the README inside the app folder itself.
+
+- [`static`](./static) contains our **static files**, such as CSS, JavaScript,
+ images, and anything else that isn't either content or Python code. Static
+ files relevant for a specific application are put into subdirectories named
+ after the application. For example, static files used by the `resources` app go in `static/resources`.
+
+- [`templates`](./templates) contains our **[Django
+ templates](https://docs.djangoproject.com/en/dev/topics/templates/)**. Like
+ with static files, templates specific to a single application are stored in a
+ subdirectory named after that application. We also have two special templates
+ here:
+
+ - `404.html`, which is our error page shown when a site was not found.
+
+ - `500.html`, which is our error page shown in the astronomically rare case
+ that we encounter an internal server error.
+
+
+Note that for both `static` and `templates`, we are not using the default Django
+directory structure which puts these directories in a directory per app (in our
+case, this would for example be ``pydis_site/apps/content/static/``).
+
+We also have a few files in here that are relevant or useful in large parts of
+the website:
+
+- [`context_processors.py`](./context_processors.py), which contains custom
+ *context processors* that add variables to the Django template context. To
+ read more, see the [`RequestContext` documentation from
+ Django](https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.RequestContext)
+
+- [`settings.py`](./settings.py), our Django settings file. This controls all
+ manner of crucial things, for instance, we use it to configure logging, our
+ connection to the database, which applications are run by the project, which
+ middleware we are using, and variables for `django-simple-bulma` (which
+ determines frontend colours & extensions for our pages).
+
+- [`urls.py`](./urls.py), the URL configuration for the project itself. Here we
+ can forward certain URL paths to our different apps, which have their own
+ `urls.py` files to configure where their subpaths will lead. These files
+ determine _which URLs will lead to which Django views_.
+
+- [`wsgi.py`](./wsgi.py), which serves as an adapter for
+ [`gunicorn`](https://github.com/benoitc/gunicorn),
+ [`uwsgi`](https://github.com/unbit/uwsgi), or other application servers to run
+ our application in production. Unless you want to test an interaction between
+ our application and those servers, you probably won't need to touch this.
+
+
+For more information about contributing to our projects, please see our
+[Contributing
+page](https://www.pythondiscord.com/pages/guides/pydis-guides/contributing/).
+
+[^1]: See [Django Glossary: project](https://docs.djangoproject.com/en/dev/glossary/#term-project)
diff --git a/pydis_site/apps/admin/__init__.py b/pydis_site/apps/admin/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/pydis_site/apps/admin/__init__.py
+++ /dev/null
diff --git a/pydis_site/apps/admin/urls.py b/pydis_site/apps/admin/urls.py
deleted file mode 100644
index a4f3e517..00000000
--- a/pydis_site/apps/admin/urls.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.contrib import admin
-from django.urls import path
-
-
-app_name = 'admin'
-urlpatterns = (
- path('', admin.site.urls),
-)
diff --git a/pydis_site/apps/api/README.md b/pydis_site/apps/api/README.md
new file mode 100644
index 00000000..1c6358b3
--- /dev/null
+++ b/pydis_site/apps/api/README.md
@@ -0,0 +1,71 @@
+# The "api" app
+
+This application takes care of most of the heavy lifting in the site, that is,
+allowing our bot to manipulate and query information stored in the site's
+database.
+
+We make heavy use of [Django REST
+Framework](https://www.django-rest-framework.org) here, which builds on top of
+Django to allow us to easily build out the
+[REST](https://en.wikipedia.org/wiki/Representational_state_transfer) API
+consumed by our bot. Working with the API app requires basic knowledge of DRF -
+the [quickstart
+guide](https://www.django-rest-framework.org/tutorial/quickstart/) is a great
+resource to get started.
+
+## Directory structure
+
+Let's look over each of the subdirectories here:
+
+- `migrations` is the standard Django migrations folder. You usually won't need
+ to edit this manually, as `python manage.py makemigrations` handles this for
+ you in case you change our models. (Note that when generating migrations and
+ Django doesn't generate a human-readable name for you, please supply one
+ manually using `-n add_this_field`.)
+
+- `models` contains our Django model definitions. We put models into subfolders
+ relevant as to where they are used - in our case, the `bot` folder contains
+ models used by our bot when working with the API. Each model is contained
+ within its own module, such as `api/models/bot/message_deletion_context.py`,
+ which contains the `MessageDeletionContext` model.
+
+- `tests` contains tests for our API. If you're unfamilar with Django testing,
+ the [Django tutorial introducing automated
+ testing](https://docs.djangoproject.com/en/dev/intro/tutorial05/) is a great
+ resource, and you can also check out code in there to see how we test it.
+
+- `viewsets` contains our [DRF
+ viewsets](https://www.django-rest-framework.org/api-guide/viewsets/), and is
+ structured similarly to the `models` folder: The `bot` subfolder contains
+ viewsets relevant to the Python Bot, and each viewset is contained within its
+ own module.
+
+The remaining modules mostly do what their name suggests:
+
+- `admin.py`, which hooks up our models to the [Django admin
+ site](https://docs.djangoproject.com/en/dev/ref/contrib/admin/).
+
+- `apps.py` contains the Django [application
+ config](https://docs.djangoproject.com/en/dev/ref/applications/) for the `api`
+ app, and is used to run any code that should run when the app is loaded.
+
+- `pagination.py` contains custom
+ [paginators](https://www.django-rest-framework.org/api-guide/pagination/) used
+ within our DRF viewsets.
+
+- `serializers.py` contains [DRF
+ serializers](https://www.django-rest-framework.org/api-guide/serializers/) for
+ our models, and also includes validation logic for the models.
+
+- `signals.py` contains [Django
+ Signals](https://docs.djangoproject.com/en/dev/topics/signals/) for running
+ custom functionality in response to events such as deletion of a model
+ instance.
+
+- `urls.py` configures Django's [URL
+ dispatcher](https://docs.djangoproject.com/en/dev/topics/http/urls/) for our
+ API endpoints.
+
+- `views.py` is for any standard Django views that don't make sense to be put
+ into DRF viewsets as they provide static data or other functionality that
+ doesn't interact with our models.
diff --git a/pydis_site/apps/api/migrations/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/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index fd5bf220..4f616986 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -10,6 +10,8 @@ from .bot import (
Nomination,
NominationEntry,
OffensiveMessage,
+ AocAccountLink,
+ AocCompletionistBlock,
OffTopicChannelName,
Reminder,
Role,
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index ac864de3..ec0e701c 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -5,6 +5,8 @@ from .deleted_message import DeletedMessage
from .documentation_link import DocumentationLink
from .infraction import Infraction
from .message import Message
+from .aoc_completionist_block import AocCompletionistBlock
+from .aoc_link import AocAccountLink
from .message_deletion_context import MessageDeletionContext
from .nomination import Nomination, NominationEntry
from .off_topic_channel_name import OffTopicChannelName
diff --git a/pydis_site/apps/api/models/bot/aoc_completionist_block.py b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
new file mode 100644
index 00000000..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/serializers.py b/pydis_site/apps/api/serializers.py
index 745aff42..c97f7dba 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -13,6 +13,8 @@ from rest_framework.settings import api_settings
from rest_framework.validators import UniqueTogetherValidator
from .models import (
+ AocAccountLink,
+ AocCompletionistBlock,
BotSetting,
DeletedMessage,
DocumentationLink,
@@ -250,6 +252,26 @@ class ReminderSerializer(ModelSerializer):
)
+class AocCompletionistBlockSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `AocCompletionistBlock` instances."""
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ model = AocCompletionistBlock
+ fields = ("user", "is_blocked", "reason")
+
+
+class AocAccountLinkSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `AocAccountLink` instances."""
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ model = AocAccountLink
+ fields = ("user", "aoc_username")
+
+
class RoleSerializer(ModelSerializer):
"""A class providing (de-)serialization of `Role` instances."""
diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py
index aa0604f6..f1107734 100644
--- a/pydis_site/apps/api/tests/test_infractions.py
+++ b/pydis_site/apps/api/tests/test_infractions.py
@@ -80,7 +80,7 @@ class InfractionTests(AuthenticatedAPITestCase):
type='superstar',
reason='This one doesn\'t matter anymore.',
active=True,
- expires_at=datetime.datetime.utcnow() + datetime.timedelta(hours=5)
+ expires_at=dt.now(timezone.utc) + datetime.timedelta(hours=5)
)
cls.voiceban_expires_later = Infraction.objects.create(
user_id=cls.user.id,
@@ -88,7 +88,7 @@ class InfractionTests(AuthenticatedAPITestCase):
type='voice_ban',
reason='Jet engine mic',
active=True,
- expires_at=datetime.datetime.utcnow() + datetime.timedelta(days=5)
+ expires_at=dt.now(timezone.utc) + datetime.timedelta(days=5)
)
def test_list_all(self):
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index 5c9ddea4..0fad467c 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -1,8 +1,7 @@
-from datetime import datetime as dt
+from datetime import datetime as dt, timezone
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase, TestCase
-from django.utils import timezone
from pydis_site.apps.api.models import (
DeletedMessage,
@@ -41,7 +40,7 @@ class NitroMessageLengthTest(TestCase):
self.context = MessageDeletionContext.objects.create(
id=50,
actor=self.user,
- creation=dt.utcnow()
+ creation=dt.now(timezone.utc)
)
def test_create(self):
@@ -99,7 +98,7 @@ class StringDunderMethodTests(SimpleTestCase):
name='shawn',
discriminator=555,
),
- creation=dt.utcnow()
+ creation=dt.now(timezone.utc)
),
embeds=[]
),
diff --git a/pydis_site/apps/api/tests/test_reminders.py b/pydis_site/apps/api/tests/test_reminders.py
index 709685bc..e17569f0 100644
--- a/pydis_site/apps/api/tests/test_reminders.py
+++ b/pydis_site/apps/api/tests/test_reminders.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timezone
from django.forms.models import model_to_dict
from django.urls import reverse
@@ -91,7 +91,7 @@ class ReminderDeletionTests(AuthenticatedAPITestCase):
cls.reminder = Reminder.objects.create(
author=cls.author,
content="Don't forget to set yourself a reminder",
- expiration=datetime.utcnow().isoformat(),
+ expiration=datetime.now(timezone.utc),
jump_url="https://www.decliningmentalfaculties.com",
channel_id=123
)
@@ -122,7 +122,7 @@ class ReminderListTests(AuthenticatedAPITestCase):
cls.reminder_one = Reminder.objects.create(
author=cls.author,
content="We should take Bikini Bottom, and push it somewhere else!",
- expiration=datetime.utcnow().isoformat(),
+ expiration=datetime.now(timezone.utc),
jump_url="https://www.icantseemyforehead.com",
channel_id=123
)
@@ -130,16 +130,17 @@ class ReminderListTests(AuthenticatedAPITestCase):
cls.reminder_two = Reminder.objects.create(
author=cls.author,
content="Gahhh-I love being purple!",
- expiration=datetime.utcnow().isoformat(),
+ expiration=datetime.now(timezone.utc),
jump_url="https://www.goofygoobersicecreampartyboat.com",
channel_id=123,
active=False
)
+ drf_format = '%Y-%m-%dT%H:%M:%S.%fZ'
cls.rem_dict_one = model_to_dict(cls.reminder_one)
- cls.rem_dict_one['expiration'] += 'Z' # Massaging a quirk of the response time format
+ cls.rem_dict_one['expiration'] = cls.rem_dict_one['expiration'].strftime(drf_format)
cls.rem_dict_two = model_to_dict(cls.reminder_two)
- cls.rem_dict_two['expiration'] += 'Z' # Massaging a quirk of the response time format
+ cls.rem_dict_two['expiration'] = cls.rem_dict_two['expiration'].strftime(drf_format)
def test_reminders_in_full_list(self):
url = reverse('api:bot:reminder-list')
@@ -175,7 +176,7 @@ class ReminderRetrieveTests(AuthenticatedAPITestCase):
cls.reminder = Reminder.objects.create(
author=cls.author,
content="Reminder content",
- expiration=datetime.utcnow().isoformat(),
+ expiration=datetime.now(timezone.utc),
jump_url="http://example.com/",
channel_id=123
)
@@ -203,7 +204,7 @@ class ReminderUpdateTests(AuthenticatedAPITestCase):
cls.reminder = Reminder.objects.create(
author=cls.author,
content="Squash those do-gooders",
- expiration=datetime.utcnow().isoformat(),
+ expiration=datetime.now(timezone.utc),
jump_url="https://www.decliningmentalfaculties.com",
channel_id=123
)
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index b0ab545b..7c55fc92 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -3,6 +3,8 @@ from rest_framework.routers import DefaultRouter
from .views import HealthcheckView, RulesView
from .viewsets import (
+ AocAccountLinkViewSet,
+ AocCompletionistBlockViewSet,
BotSettingViewSet,
DeletedMessageViewSet,
DocumentationLinkViewSet,
@@ -35,6 +37,14 @@ bot_router.register(
DocumentationLinkViewSet
)
bot_router.register(
+ "aoc-account-links",
+ AocAccountLinkViewSet
+)
+bot_router.register(
+ "aoc-completionist-blocks",
+ AocCompletionistBlockViewSet
+)
+bot_router.register(
'infractions',
InfractionViewSet
)
diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
index f133e77f..5fc1d64f 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -7,6 +7,8 @@ from .bot import (
InfractionViewSet,
NominationViewSet,
OffensiveMessageViewSet,
+ AocAccountLinkViewSet,
+ AocCompletionistBlockViewSet,
OffTopicChannelNameViewSet,
ReminderViewSet,
RoleViewSet,
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
index 84b87eab..f1d84729 100644
--- a/pydis_site/apps/api/viewsets/bot/__init__.py
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -7,6 +7,8 @@ from .infraction import InfractionViewSet
from .nomination import NominationViewSet
from .off_topic_channel_name import OffTopicChannelNameViewSet
from .offensive_message import OffensiveMessageViewSet
+from .aoc_link import AocAccountLinkViewSet
+from .aoc_completionist_block import AocCompletionistBlockViewSet
from .reminder import ReminderViewSet
from .role import RoleViewSet
from .user import UserViewSet
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
new file mode 100644
index 00000000..3a4cec60
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
@@ -0,0 +1,73 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.mixins import (
+ CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import AocCompletionistBlock
+from pydis_site.apps.api.serializers import AocCompletionistBlockSerializer
+
+
+class AocCompletionistBlockViewSet(
+ GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin
+):
+ """
+ View providing management for Users blocked from gettign the AoC completionist Role.
+
+ ## Routes
+
+ ### GET /bot/aoc-completionist-blocks/
+ Returns all the AoC completionist blocks
+
+ #### Response format
+ >>> [
+ ... {
+ ... "user": 2,
+ ... "is_blocked": False,
+ ... "reason": "Too good to be true"
+ ... }
+ ... ]
+
+
+ ### GET /bot/aoc-completionist-blocks/<user__id:int>
+ Retrieve a single Block by User ID
+
+ #### Response format
+ >>>
+ ... {
+ ... "user": 2,
+ ... "is_blocked": False,
+ ... "reason": "Too good to be true"
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned if an AoC completionist block with the given `user__id` was not found.
+
+ ### POST /bot/aoc-completionist-blocks
+ Adds a single AoC completionist block
+
+ #### Request body
+ >>> {
+ ... "user": int,
+ ... "is_blocked": bool,
+ ... "reason": string
+ ... }
+
+ #### Status codes
+ - 204: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### DELETE /bot/aoc-completionist-blocks/<user__id:int>
+ Deletes the AoC Completionist block item with the given `user__id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: returned if the AoC Completionist block with the given `user__id` was not found
+
+ """
+
+ serializer_class = AocCompletionistBlockSerializer
+ queryset = AocCompletionistBlock.objects.all()
+ filter_backends = (DjangoFilterBackend,)
+ filter_fields = ("user__id", "is_blocked")
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py
new file mode 100644
index 00000000..9f22c1a1
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py
@@ -0,0 +1,71 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.mixins import (
+ CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import AocAccountLink
+from pydis_site.apps.api.serializers import AocAccountLinkSerializer
+
+
+class AocAccountLinkViewSet(
+ GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin
+):
+ """
+ View providing management for Users who linked their AoC accounts to their Discord Account.
+
+ ## Routes
+
+ ### GET /bot/aoc-account-links
+ Returns all the AoC account links
+
+ #### Response format
+ >>> [
+ ... {
+ ... "user": 2,
+ ... "aoc_username": "AoCUser1"
+ ... },
+ ... ...
+ ... ]
+
+
+ ### GET /bot/aoc-account-links/<user__id:int>
+ Retrieve a AoC account link by User ID
+
+ #### Response format
+ >>>
+ ... {
+ ... "user": 2,
+ ... "aoc_username": "AoCUser1"
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned if an AoC account link with the given `user__id` was not found.
+
+ ### POST /bot/aoc-account-links
+ Adds a single AoC account link block
+
+ #### Request body
+ >>> {
+ ... 'user': int,
+ ... 'aoc_username': str
+ ... }
+
+ #### Status codes
+ - 204: returned on success
+ - 400: if one of the given fields was invalid
+
+ ### DELETE /bot/aoc-account-links/<user__id:int>
+ Deletes the AoC account link item with the given `user__id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: returned if the AoC account link with the given `user__id` was not found
+
+ """
+
+ serializer_class = AocAccountLinkSerializer
+ queryset = AocAccountLink.objects.all()
+ filter_backends = (DjangoFilterBackend,)
+ filter_fields = ("user__id",)
diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py
index 7e7adbca..7f31292f 100644
--- a/pydis_site/apps/api/viewsets/bot/infraction.py
+++ b/pydis_site/apps/api/viewsets/bot/infraction.py
@@ -3,6 +3,7 @@ from datetime import datetime
from django.db import IntegrityError
from django.db.models import QuerySet
from django.http.request import HttpRequest
+from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
@@ -184,20 +185,24 @@ class InfractionViewSet(
filter_expires_after = self.request.query_params.get('expires_after')
if filter_expires_after:
try:
- additional_filters['expires_at__gte'] = datetime.fromisoformat(
- filter_expires_after
- )
+ expires_after_parsed = datetime.fromisoformat(filter_expires_after)
except ValueError:
raise ValidationError({'expires_after': ['failed to convert to datetime']})
+ additional_filters['expires_at__gte'] = timezone.make_aware(
+ expires_after_parsed,
+ timezone=timezone.utc,
+ )
filter_expires_before = self.request.query_params.get('expires_before')
if filter_expires_before:
try:
- additional_filters['expires_at__lte'] = datetime.fromisoformat(
- filter_expires_before
- )
+ expires_before_parsed = datetime.fromisoformat(filter_expires_before)
except ValueError:
raise ValidationError({'expires_before': ['failed to convert to datetime']})
+ additional_filters['expires_at__lte'] = timezone.make_aware(
+ expires_before_parsed,
+ timezone=timezone.utc,
+ )
if 'expires_at__lte' in additional_filters and 'expires_at__gte' in additional_filters:
if additional_filters['expires_at__gte'] > additional_filters['expires_at__lte']:
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/migrations/__init__.py b/pydis_site/apps/content/migrations/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/pydis_site/apps/content/migrations/__init__.py
+++ /dev/null
diff --git a/pydis_site/apps/content/resources/server-info/roles.md b/pydis_site/apps/content/resources/server-info/roles.md
index d9e0af15..409e037e 100644
--- a/pydis_site/apps/content/resources/server-info/roles.md
+++ b/pydis_site/apps/content/resources/server-info/roles.md
@@ -72,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
@@ -84,8 +84,8 @@ In addition to the informal descriptions below, we've also written down a more f
### <span class="fas fa-circle" style="color:#a1d1ff"></span> DevOps
**Description:** A role for staff involved with the DevOps toolchain of our core projects.
-### <span class="fas fa-circle" style="color:#f8d188"></span> Project Teams
-**Description:** Staff can join teams which work on specific projects in the organisation, such as our code jams, media projects, and more.
+### <span class="fas fa-circle" style="color:#7de29c"></span> Events Team
+**Description:** The events team are staff members who help plan and execute Python Discord events. This can range from the Code Jam, to Pixels, to our survey, specific workshops we want to run, and more.
### <span class="fas fa-circle" style="color:#eecd36"></span> Helpers
**Description:** This is the core staff role in our organization: All staff members have the Helpers role.
diff --git a/pydis_site/apps/home/tests/test_repodata_helpers.py b/pydis_site/apps/home/tests/test_repodata_helpers.py
index 5634bc9b..d43bd28e 100644
--- a/pydis_site/apps/home/tests/test_repodata_helpers.py
+++ b/pydis_site/apps/home/tests/test_repodata_helpers.py
@@ -122,7 +122,10 @@ class TestRepositoryMetadataHelpers(TestCase):
"""Tests that fallback to the database is performed when we get garbage back."""
mock_get.return_value.json.return_value = ['garbage']
- metadata = self.home_view._get_repo_data()
+ # Capture logs and ensure the problematic response is logged
+ with self.assertLogs():
+ metadata = self.home_view._get_repo_data()
+
self.assertEquals(len(metadata), 0)
def test_cleans_up_stale_metadata(self):
diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py
index e28a3a00..69e706c5 100644
--- a/pydis_site/apps/home/views/home.py
+++ b/pydis_site/apps/home/views/home.py
@@ -10,7 +10,6 @@ from django.views import View
from pydis_site import settings
from pydis_site.apps.home.models import RepositoryMetadata
-from pydis_site.constants import GITHUB_TOKEN, TIMEOUT_PERIOD
log = logging.getLogger(__name__)
@@ -43,8 +42,8 @@ class HomeView(View):
# specifically, GitHub will reject any requests from us due to the
# invalid header. We can make a limited number of anonymous requests
# though, which is useful for testing.
- if GITHUB_TOKEN:
- self.headers = {"Authorization": f"token {GITHUB_TOKEN}"}
+ if settings.GITHUB_TOKEN:
+ self.headers = {"Authorization": f"token {settings.GITHUB_TOKEN}"}
else:
self.headers = {}
@@ -60,7 +59,7 @@ class HomeView(View):
api_data: List[dict] = requests.get(
self.github_api,
headers=self.headers,
- timeout=TIMEOUT_PERIOD
+ timeout=settings.TIMEOUT_PERIOD
).json()
except requests.exceptions.Timeout:
log.error("Request to fetch GitHub repository metadata for timed out!")
diff --git a/pydis_site/constants.py b/pydis_site/constants.py
deleted file mode 100644
index e913f40f..00000000
--- a/pydis_site/constants.py
+++ /dev/null
@@ -1,6 +0,0 @@
-import os
-
-GIT_SHA = os.environ.get("GIT_SHA", "development")
-GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
-# How long to wait for synchronous requests before timing out
-TIMEOUT_PERIOD = int(os.environ.get("TIMEOUT_PERIOD", 5))
diff --git a/pydis_site/context_processors.py b/pydis_site/context_processors.py
index 6937a3db..0e8b4a94 100644
--- a/pydis_site/context_processors.py
+++ b/pydis_site/context_processors.py
@@ -1,8 +1,7 @@
+from django.conf import settings
from django.template import RequestContext
-from pydis_site.constants import GIT_SHA
-
def git_sha_processor(_: RequestContext) -> dict:
"""Expose the git SHA for this repo to all views."""
- return {'git_sha': GIT_SHA}
+ return {'git_sha': settings.GIT_SHA}
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
index 3b146f2c..17f220f3 100644
--- a/pydis_site/settings.py
+++ b/pydis_site/settings.py
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/2.1/ref/settings/
import os
import secrets
import sys
+import warnings
from pathlib import Path
from socket import gethostbyname, gethostname
@@ -20,15 +21,20 @@ import environ
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
-from pydis_site.constants import GIT_SHA
env = environ.Env(
DEBUG=(bool, False),
SITE_DSN=(str, ""),
BUILDING_DOCKER=(bool, False),
STATIC_BUILD=(bool, False),
+ GIT_SHA=(str, 'development'),
+ TIMEOUT_PERIOD=(int, 5),
+ GITHUB_TOKEN=(str, None),
)
+GIT_SHA = env("GIT_SHA")
+GITHUB_TOKEN = env("GITHUB_TOKEN")
+
sentry_sdk.init(
dsn=env('SITE_DSN'),
integrations=[DjangoIntegration()],
@@ -48,10 +54,26 @@ if DEBUG:
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*'])
SECRET_KEY = "yellow polkadot bikini" # noqa: S105
+ # Prevent verbose warnings emitted when passing a non-timezone aware
+ # datetime object to the database, whilst we have time zone support
+ # active. See the Django documentation for more details:
+ # https://docs.djangoproject.com/en/dev/topics/i18n/timezones/
+ warnings.filterwarnings(
+ 'error', r"DateTimeField .* received a naive datetime",
+ RuntimeWarning, r'django\.db\.models\.fields',
+ )
+
elif 'CI' in os.environ:
ALLOWED_HOSTS = ['*']
SECRET_KEY = secrets.token_urlsafe(32)
+ # See above. We run with `CI=true`, but debug unset in GitHub Actions,
+ # so we also want to filter it there.
+ warnings.filterwarnings(
+ 'error', r"DateTimeField .* received a naive datetime",
+ RuntimeWarning, r'django\.db\.models\.fields',
+ )
+
else:
ALLOWED_HOSTS = env.list(
'ALLOWED_HOSTS',
@@ -288,3 +310,6 @@ CONTENT_PAGES_PATH = Path(BASE_DIR, "pydis_site", "apps", "content", "resources"
# Path for redirection links
REDIRECTIONS_PATH = Path(BASE_DIR, "pydis_site", "apps", "redirect", "redirects.yaml")
+
+# How long to wait for synchronous requests before timing out
+TIMEOUT_PERIOD = env("TIMEOUT_PERIOD")
diff --git a/pydis_site/templates/staff/logs.html b/pydis_site/templates/staff/logs.html
index 8c92836a..7bd6ba29 100644
--- a/pydis_site/templates/staff/logs.html
+++ b/pydis_site/templates/staff/logs.html
@@ -18,8 +18,12 @@
<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.channel_id }})
+ </span>
</div>
<div class="discord-message-content">
{{ message.content | escape | visible_newlines | safe }}
diff --git a/pydis_site/utils/resources.py b/pydis_site/utils/resources.py
deleted file mode 100644
index 637fd785..00000000
--- a/pydis_site/utils/resources.py
+++ /dev/null
@@ -1,91 +0,0 @@
-from __future__ import annotations
-
-import glob
-import typing
-from dataclasses import dataclass
-
-import yaml
-
-
-@dataclass
-class URL:
- """A class representing a link to a resource."""
-
- icon: str
- title: str
- url: str
-
-
-class Resource:
- """A class representing a resource on the resource page."""
-
- description: str
- name: str
- payment: str
- payment_description: typing.Optional[str]
- urls: typing.List[URL]
-
- def __repr__(self):
- """Return a representation of the resource."""
- return f"<Resource name={self.name}>"
-
- @classmethod
- def construct_from_yaml(cls, yaml_data: typing.TextIO) -> Resource:
- """Construct a Resource object from the provided YAML."""
- resource = cls()
-
- loaded = yaml.safe_load(yaml_data)
-
- resource.__dict__.update(loaded)
-
- resource.__dict__["urls"] = []
-
- for url in loaded["urls"]:
- resource.__dict__["urls"].append(URL(**url))
-
- return resource
-
-
-class Category:
- """A class representing a resource on the resources page."""
-
- resources: typing.List[Resource]
- name: str
- description: str
-
- def __repr__(self):
- """Return a representation of the category."""
- return f"<Category name={self.name}>"
-
- @classmethod
- def construct_from_directory(cls, directory: str) -> Category:
- """Construct a Category object from the provided directory."""
- category = cls()
-
- with open(f"{directory}/_category_info.yaml") as category_info:
- category_data = yaml.safe_load(category_info)
-
- category.__dict__.update(category_data)
-
- category.resources = []
-
- for resource in glob.glob(f"{directory}/*.yaml"):
- if resource == f"{directory}/_category_info.yaml":
- continue
-
- with open(resource) as res_file:
- category.resources.append(
- Resource.construct_from_yaml(res_file)
- )
-
- return category
-
-
-def load_categories(order: typing.List[str]) -> typing.List[Category]:
- """Load the categories specified in the order list and return them."""
- categories = []
- for cat in order:
- direc = "pydis_site/apps/home/resources/" + cat
- categories.append(Category.construct_from_directory(direc))
-
- return categories