From 7f617657068e2cf877edfb06a70d435dd5dfbdc0 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 25 Feb 2022 01:09:58 +0100 Subject: Remove and merge superfluous modules The constants module more or less did what belongs to the settings. --- pydis_site/settings.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) (limited to 'pydis_site/settings.py') diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 3b146f2c..4f2b4aef 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -20,15 +20,20 @@ import environ import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration -from pydis_site.constants import GIT_SHA env = environ.Env( DEBUG=(bool, False), SITE_DSN=(str, ""), BUILDING_DOCKER=(bool, False), STATIC_BUILD=(bool, False), + GIT_SHA=(str, 'development'), + TIMEOUT_PERIOD=(int, 5), + GITHUB_TOKEN=(str, None), ) +GIT_SHA = env("GIT_SHA") +GITHUB_TOKEN = env("GITHUB_TOKEN") + sentry_sdk.init( dsn=env('SITE_DSN'), integrations=[DjangoIntegration()], @@ -288,3 +293,6 @@ CONTENT_PAGES_PATH = Path(BASE_DIR, "pydis_site", "apps", "content", "resources" # Path for redirection links REDIRECTIONS_PATH = Path(BASE_DIR, "pydis_site", "apps", "redirect", "redirects.yaml") + +# How long to wait for synchronous requests before timing out +TIMEOUT_PERIOD = env("TIMEOUT_PERIOD") -- cgit v1.2.3 From 66061855ca2098fae7e80fa2e1abb29f810495f1 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 26 Feb 2022 14:36:53 +0100 Subject: Fix timezone awareness warnings Add a `warnings.warnings` clause to prevent these from being raised again in the future, and raise a full traceback if they don't. --- pydis_site/apps/api/tests/test_infractions.py | 4 ++-- pydis_site/apps/api/tests/test_models.py | 7 +++---- pydis_site/apps/api/tests/test_reminders.py | 17 +++++++++-------- pydis_site/apps/api/viewsets/bot/infraction.py | 11 +++++------ pydis_site/settings.py | 17 +++++++++++++++++ 5 files changed, 36 insertions(+), 20 deletions(-) (limited to 'pydis_site/settings.py') diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index aa0604f6..f1107734 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -80,7 +80,7 @@ class InfractionTests(AuthenticatedAPITestCase): type='superstar', reason='This one doesn\'t matter anymore.', active=True, - expires_at=datetime.datetime.utcnow() + datetime.timedelta(hours=5) + expires_at=dt.now(timezone.utc) + datetime.timedelta(hours=5) ) cls.voiceban_expires_later = Infraction.objects.create( user_id=cls.user.id, @@ -88,7 +88,7 @@ class InfractionTests(AuthenticatedAPITestCase): type='voice_ban', reason='Jet engine mic', active=True, - expires_at=datetime.datetime.utcnow() + datetime.timedelta(days=5) + expires_at=dt.now(timezone.utc) + datetime.timedelta(days=5) ) def test_list_all(self): diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index 5c9ddea4..0fad467c 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -1,8 +1,7 @@ -from datetime import datetime as dt +from datetime import datetime as dt, timezone from django.core.exceptions import ValidationError from django.test import SimpleTestCase, TestCase -from django.utils import timezone from pydis_site.apps.api.models import ( DeletedMessage, @@ -41,7 +40,7 @@ class NitroMessageLengthTest(TestCase): self.context = MessageDeletionContext.objects.create( id=50, actor=self.user, - creation=dt.utcnow() + creation=dt.now(timezone.utc) ) def test_create(self): @@ -99,7 +98,7 @@ class StringDunderMethodTests(SimpleTestCase): name='shawn', discriminator=555, ), - creation=dt.utcnow() + creation=dt.now(timezone.utc) ), embeds=[] ), diff --git a/pydis_site/apps/api/tests/test_reminders.py b/pydis_site/apps/api/tests/test_reminders.py index 709685bc..e17569f0 100644 --- a/pydis_site/apps/api/tests/test_reminders.py +++ b/pydis_site/apps/api/tests/test_reminders.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from django.forms.models import model_to_dict from django.urls import reverse @@ -91,7 +91,7 @@ class ReminderDeletionTests(AuthenticatedAPITestCase): cls.reminder = Reminder.objects.create( author=cls.author, content="Don't forget to set yourself a reminder", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(timezone.utc), jump_url="https://www.decliningmentalfaculties.com", channel_id=123 ) @@ -122,7 +122,7 @@ class ReminderListTests(AuthenticatedAPITestCase): cls.reminder_one = Reminder.objects.create( author=cls.author, content="We should take Bikini Bottom, and push it somewhere else!", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(timezone.utc), jump_url="https://www.icantseemyforehead.com", channel_id=123 ) @@ -130,16 +130,17 @@ class ReminderListTests(AuthenticatedAPITestCase): cls.reminder_two = Reminder.objects.create( author=cls.author, content="Gahhh-I love being purple!", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(timezone.utc), jump_url="https://www.goofygoobersicecreampartyboat.com", channel_id=123, active=False ) + drf_format = '%Y-%m-%dT%H:%M:%S.%fZ' cls.rem_dict_one = model_to_dict(cls.reminder_one) - cls.rem_dict_one['expiration'] += 'Z' # Massaging a quirk of the response time format + cls.rem_dict_one['expiration'] = cls.rem_dict_one['expiration'].strftime(drf_format) cls.rem_dict_two = model_to_dict(cls.reminder_two) - cls.rem_dict_two['expiration'] += 'Z' # Massaging a quirk of the response time format + cls.rem_dict_two['expiration'] = cls.rem_dict_two['expiration'].strftime(drf_format) def test_reminders_in_full_list(self): url = reverse('api:bot:reminder-list') @@ -175,7 +176,7 @@ class ReminderRetrieveTests(AuthenticatedAPITestCase): cls.reminder = Reminder.objects.create( author=cls.author, content="Reminder content", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(timezone.utc), jump_url="http://example.com/", channel_id=123 ) @@ -203,7 +204,7 @@ class ReminderUpdateTests(AuthenticatedAPITestCase): cls.reminder = Reminder.objects.create( author=cls.author, content="Squash those do-gooders", - expiration=datetime.utcnow().isoformat(), + expiration=datetime.now(timezone.utc), jump_url="https://www.decliningmentalfaculties.com", channel_id=123 ) diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index 7e7adbca..d82090aa 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -3,6 +3,7 @@ from datetime import datetime from django.db import IntegrityError from django.db.models import QuerySet from django.http.request import HttpRequest +from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from rest_framework.decorators import action from rest_framework.exceptions import ValidationError @@ -184,20 +185,18 @@ class InfractionViewSet( filter_expires_after = self.request.query_params.get('expires_after') if filter_expires_after: try: - additional_filters['expires_at__gte'] = datetime.fromisoformat( - filter_expires_after - ) + expires_after_parsed = datetime.fromisoformat(filter_expires_after) except ValueError: raise ValidationError({'expires_after': ['failed to convert to datetime']}) + additional_filters['expires_at__gte'] = timezone.make_aware(expires_after_parsed) filter_expires_before = self.request.query_params.get('expires_before') if filter_expires_before: try: - additional_filters['expires_at__lte'] = datetime.fromisoformat( - filter_expires_before - ) + expires_before_parsed = datetime.fromisoformat(filter_expires_before) except ValueError: raise ValidationError({'expires_before': ['failed to convert to datetime']}) + additional_filters['expires_at__lte'] = timezone.make_aware(expires_before_parsed) if 'expires_at__lte' in additional_filters and 'expires_at__gte' in additional_filters: if additional_filters['expires_at__gte'] > additional_filters['expires_at__lte']: diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 4f2b4aef..17f220f3 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/2.1/ref/settings/ import os import secrets import sys +import warnings from pathlib import Path from socket import gethostbyname, gethostname @@ -53,10 +54,26 @@ if DEBUG: ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*']) SECRET_KEY = "yellow polkadot bikini" # noqa: S105 + # Prevent verbose warnings emitted when passing a non-timezone aware + # datetime object to the database, whilst we have time zone support + # active. See the Django documentation for more details: + # https://docs.djangoproject.com/en/dev/topics/i18n/timezones/ + warnings.filterwarnings( + 'error', r"DateTimeField .* received a naive datetime", + RuntimeWarning, r'django\.db\.models\.fields', + ) + elif 'CI' in os.environ: ALLOWED_HOSTS = ['*'] SECRET_KEY = secrets.token_urlsafe(32) + # See above. We run with `CI=true`, but debug unset in GitHub Actions, + # so we also want to filter it there. + warnings.filterwarnings( + 'error', r"DateTimeField .* received a naive datetime", + RuntimeWarning, r'django\.db\.models\.fields', + ) + else: ALLOWED_HOSTS = env.list( 'ALLOWED_HOSTS', -- cgit v1.2.3 From 4edb368c39c96f20e41a8874b0f77f30910c8ce7 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 11 Jul 2022 05:10:19 +0400 Subject: Bump Django To 3.2 Bumps the current django version to 3.2 For the most part, there are no major changes here. The app configuration names were incorrect, and the new version no longer ignores that, so those were updated. The new version also requires explicitly defining the field type for primary keys if they are not defined on the model. Signed-off-by: Hassan Abouelela --- poetry.lock | 13 +++++-------- pydis_site/apps/content/apps.py | 2 +- pydis_site/apps/events/apps.py | 2 +- pydis_site/apps/redirect/apps.py | 2 +- pydis_site/apps/resources/apps.py | 2 +- pydis_site/apps/staff/apps.py | 2 +- pydis_site/settings.py | 3 +++ pyproject.toml | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) (limited to 'pydis_site/settings.py') diff --git a/poetry.lock b/poetry.lock index 826c4fca..d855bdaf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -131,19 +131,19 @@ python-versions = "*" [[package]] name = "django" -version = "3.1.14" +version = "3.2.14" description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -asgiref = ">=3.2.10,<4" +asgiref = ">=3.3.2,<4" pytz = "*" sqlparse = ">=0.2.2" [package.extras] -argon2 = ["argon2-cffi (>=16.1.0)"] +argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] [[package]] @@ -867,7 +867,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "91913e2e96ab2e0e78a09334241062359605135d64f458e710f66d00fb670e05" +content-hash = "e40d87f94732c314e0fb3cd2c9023ea47d66d3c3d340085eb6df9c1fe1412529" [metadata.files] anyio = [ @@ -958,10 +958,7 @@ distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, ] -django = [ - {file = "Django-3.1.14-py3-none-any.whl", hash = "sha256:0fabc786489af16ad87a8c170ba9d42bfd23f7b699bd5ef05675864e8d012859"}, - {file = "Django-3.1.14.tar.gz", hash = "sha256:72a4a5a136a214c39cf016ccdd6b69e2aa08c7479c66d93f3a9b5e4bb9d8a347"}, -] +django = [] django-distill = [ {file = "django-distill-2.9.2.tar.gz", hash = "sha256:91d5f45c2ff78b8efd4805ff5ec17df4ba815bbf51ca12a2cea65727d2f1d42e"}, ] diff --git a/pydis_site/apps/content/apps.py b/pydis_site/apps/content/apps.py index 1e300a48..96019e1c 100644 --- a/pydis_site/apps/content/apps.py +++ b/pydis_site/apps/content/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class ContentConfig(AppConfig): """Django AppConfig for content app.""" - name = 'content' + name = 'pydis_site.apps.content' diff --git a/pydis_site/apps/events/apps.py b/pydis_site/apps/events/apps.py index a1cf09ef..70762bc2 100644 --- a/pydis_site/apps/events/apps.py +++ b/pydis_site/apps/events/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class EventsConfig(AppConfig): """Django AppConfig for events app.""" - name = 'events' + name = 'pydis_site.apps.events' diff --git a/pydis_site/apps/redirect/apps.py b/pydis_site/apps/redirect/apps.py index 9b70d169..0234bc93 100644 --- a/pydis_site/apps/redirect/apps.py +++ b/pydis_site/apps/redirect/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class RedirectConfig(AppConfig): """AppConfig instance for Redirect app.""" - name = 'redirect' + name = 'pydis_site.apps.redirect' diff --git a/pydis_site/apps/resources/apps.py b/pydis_site/apps/resources/apps.py index e0c235bd..93117654 100644 --- a/pydis_site/apps/resources/apps.py +++ b/pydis_site/apps/resources/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class ResourcesConfig(AppConfig): """AppConfig instance for Resources app.""" - name = 'resources' + name = 'pydis_site.apps.resources' diff --git a/pydis_site/apps/staff/apps.py b/pydis_site/apps/staff/apps.py index 70a15f40..d68a80c3 100644 --- a/pydis_site/apps/staff/apps.py +++ b/pydis_site/apps/staff/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class StaffConfig(AppConfig): """Django AppConfig for the staff app.""" - name = 'staff' + name = 'pydis_site.apps.staff' diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 17f220f3..03c16f4b 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -219,6 +219,9 @@ if DEBUG: else: PARENT_HOST = env('PARENT_HOST', default='pythondiscord.com') +# Django Model Configuration +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + # Django REST framework # https://www.django-rest-framework.org REST_FRAMEWORK = { diff --git a/pyproject.toml b/pyproject.toml index 6ef7c407..75292eb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "MIT" [tool.poetry.dependencies] python = "3.9.*" -django = "~=3.1.14" +django = "~=3.2.14" django-environ = "~=0.4.5" django-filter = "~=21.1" djangorestframework = "~=3.12.0" -- cgit v1.2.3 From fe4def75dc0a316789cec068a574713a2b2af92f Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 12 Jul 2022 09:25:05 +0400 Subject: Add GitHub Artifact API View Adds an API route to fetch GitHub build artifacts through a GitHub app. Signed-off-by: Hassan Abouelela --- .gitignore | 3 + poetry.lock | 67 +++++- pydis_site/apps/api/github_utils.py | 183 ++++++++++++++++ pydis_site/apps/api/tests/test_github_utils.py | 287 +++++++++++++++++++++++++ pydis_site/apps/api/urls.py | 9 +- pydis_site/apps/api/views.py | 53 +++++ pydis_site/settings.py | 10 +- pyproject.toml | 1 + 8 files changed, 609 insertions(+), 4 deletions(-) create mode 100644 pydis_site/apps/api/github_utils.py create mode 100644 pydis_site/apps/api/tests/test_github_utils.py (limited to 'pydis_site/settings.py') diff --git a/.gitignore b/.gitignore index 45073da5..4fc4417d 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ log.* # Mac/OSX .DS_Store + +# Private keys +*.pem diff --git a/poetry.lock b/poetry.lock index f6576fba..1bee4397 100644 --- a/poetry.lock +++ b/poetry.lock @@ -67,6 +67,17 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.3.1" @@ -121,6 +132,25 @@ requests = ">=1.0.0" [package.extras] yaml = ["PyYAML (>=3.10)"] +[[package]] +name = "cryptography" +version = "37.0.4" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +sdist = ["setuptools_rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] + [[package]] name = "distlib" version = "0.3.4" @@ -607,6 +637,14 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "pydocstyle" version = "6.1.1" @@ -637,6 +675,23 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "pyjwt" +version = "2.4.0" +description = "JSON Web Token implementation in Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cryptography = {version = ">=3.3.1", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["sphinx", "sphinx-rtd-theme", "zope.interface", "cryptography (>=3.3.1)", "pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)", "mypy", "pre-commit"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"] + [[package]] name = "python-dotenv" version = "0.17.1" @@ -876,7 +931,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "e71d10c3d478c5d99e842f4c449a093caa1d4b2d255eb0dfb19843c5265d4aca" +content-hash = "c656c07f40d32ee7d30c19a7084b40e1e851209a362a3fe882aa03c2fd286454" [metadata.files] anyio = [ @@ -896,6 +951,7 @@ certifi = [ {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, ] +cffi = [] cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, @@ -963,6 +1019,7 @@ coveralls = [ {file = "coveralls-2.2.0-py2.py3-none-any.whl", hash = "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc"}, {file = "coveralls-2.2.0.tar.gz", hash = "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"}, ] +cryptography = [] distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, @@ -1157,6 +1214,10 @@ pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] +pycparser = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] pydocstyle = [ {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, @@ -1166,6 +1227,10 @@ pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] +pyjwt = [ + {file = "PyJWT-2.4.0-py3-none-any.whl", hash = "sha256:72d1d253f32dbd4f5c88eaf1fdc62f3a19f676ccbadb9dbc5d07e951b2b26daf"}, + {file = "PyJWT-2.4.0.tar.gz", hash = "sha256:d42908208c699b3b973cbeb01a969ba6a96c821eefb1c5bfe4c390c01d67abba"}, +] python-dotenv = [ {file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"}, {file = "python_dotenv-0.17.1-py2.py3-none-any.whl", hash = "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544"}, diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py new file mode 100644 index 00000000..70dccdff --- /dev/null +++ b/pydis_site/apps/api/github_utils.py @@ -0,0 +1,183 @@ +"""Utilities for working with the GitHub API.""" + +import asyncio +import datetime +import math + +import httpx +import jwt +from asgiref.sync import async_to_sync + +from pydis_site import settings + +MAX_POLLS = 20 +"""The maximum number of attempts at fetching a workflow run.""" + + +class ArtifactProcessingError(Exception): + """Base exception for other errors related to processing a GitHub artifact.""" + + status: int + + +class UnauthorizedError(ArtifactProcessingError): + """The application does not have permission to access the requested repo.""" + + status = 401 + + +class NotFoundError(ArtifactProcessingError): + """The requested resource could not be found.""" + + status = 404 + + +class ActionFailedError(ArtifactProcessingError): + """The requested workflow did not conclude successfully.""" + + status = 400 + + +class RunTimeoutError(ArtifactProcessingError): + """The requested workflow run was not ready in time.""" + + status = 408 + + +def generate_token() -> str: + """ + Generate a JWT token to access the GitHub API. + + The token is valid for roughly 10 minutes after generation, before the API starts + returning 401s. + + Refer to: + https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app + """ + now = datetime.datetime.now() + return jwt.encode( + { + "iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()), # Issued at + "exp": math.floor((now + datetime.timedelta(minutes=9)).timestamp()), # Expires at + "iss": settings.GITHUB_OAUTH_APP_ID, + }, + settings.GITHUB_OAUTH_KEY, + algorithm="RS256" + ) + + +async def authorize(owner: str, repo: str) -> httpx.AsyncClient: + """ + Get an access token for the requested repository. + + The process is roughly: + - GET app/installations to get a list of all app installations + - POST to get a token to access the given app + - GET installation/repositories and check if the requested one is part of those + """ + client = httpx.AsyncClient( + base_url=settings.GITHUB_API, + headers={"Authorization": f"bearer {generate_token()}"}, + timeout=settings.TIMEOUT_PERIOD, + ) + + try: + # Get a list of app installations we have access to + apps = await client.get("app/installations") + apps.raise_for_status() + + for app in apps.json(): + # Look for an installation with the right owner + if app["account"]["login"] != owner: + continue + + # Get the repositories of the specified owner + app_token = await client.post(app["access_tokens_url"]) + app_token.raise_for_status() + client.headers["Authorization"] = f"bearer {app_token.json()['token']}" + + repos = await client.get("installation/repositories") + repos.raise_for_status() + + # Search for the request repository + for accessible_repo in repos.json()["repositories"]: + if accessible_repo["name"] == repo: + # We've found the correct repository, and it's accessible with the current auth + return client + + raise NotFoundError( + "Could not find the requested repository. Make sure the application can access it." + ) + + except BaseException as e: + # Close the client if we encountered an unexpected exception + await client.aclose() + raise e + + +async def wait_for_run(client: httpx.AsyncClient, run: dict) -> str: + """Wait for the provided `run` to finish, and return the URL to its artifacts.""" + polls = 0 + while polls <= MAX_POLLS: + if run["status"] != "completed": + # The action is still processing, wait a bit longer + polls += 1 + await asyncio.sleep(10) + + elif run["conclusion"] != "success": + # The action failed, or did not run + raise ActionFailedError(f"The requested workflow ended with: {run['conclusion']}") + + else: + # The desired action was found, and it ended successfully + return run["artifacts_url"] + + run = await client.get(run["url"]) + run.raise_for_status() + run = run.json() + + raise RunTimeoutError("The requested workflow was not ready in time.") + + +@async_to_sync +async def get_artifact( + owner: str, repo: str, sha: str, action_name: str, artifact_name: str +) -> str: + """Get a download URL for a build artifact.""" + client = await authorize(owner, repo) + + try: + # Get the workflow runs for this repository + runs = await client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100}) + runs.raise_for_status() + runs = runs.json() + + # Filter the runs for the one associated with the given SHA + for run in runs["workflow_runs"]: + if run["name"] == action_name and sha == run["head_sha"]: + break + else: + raise NotFoundError( + "Could not find a run matching the provided settings in the previous hundred runs." + ) + + # Wait for the workflow to finish + url = await wait_for_run(client, run) + + # Filter the artifacts, and return the download URL + artifacts = await client.get(url) + artifacts.raise_for_status() + + for artifact in artifacts.json()["artifacts"]: + if artifact["name"] == artifact_name: + data = await client.get(artifact["archive_download_url"]) + if data.status_code == 302: + return str(data.next_request.url) + + # The following line is left untested since it should in theory be impossible + data.raise_for_status() # pragma: no cover + + raise NotFoundError("Could not find an artifact matching the provided name.") + + finally: + await client.aclose() diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py new file mode 100644 index 00000000..dc17d609 --- /dev/null +++ b/pydis_site/apps/api/tests/test_github_utils.py @@ -0,0 +1,287 @@ +import asyncio +import datetime +import random +import unittest +from unittest import mock + +import django.test +import httpx +import jwt +import rest_framework.response +import rest_framework.test +from django.urls import reverse + +from .. import github_utils + + +def patched_raise_for_status(response: httpx.Response): + """Fake implementation of raise_for_status which does not need a request to be set.""" + if response.status_code // 100 != 2: # pragma: no cover + raise httpx.HTTPStatusError( + f"Non 2xx response code: {response.status_code}", + request=getattr(response, "_request", httpx.Request("GET", "")), + response=response + ) + + +class GeneralUtilityTests(unittest.TestCase): + """Test the utility methods which do not fit in another class.""" + + def test_token_generation(self): + """Test that the a valid JWT token is generated.""" + def encode(payload: dict, _: str, algorithm: str, *args, **kwargs) -> str: + """ + Intercept the encode method. + + It is performed with an algorithm which does not require a PEM key, as it may + not be available in testing environments. + """ + self.assertEqual("RS256", algorithm, "The GitHub App JWT must be signed using RS256.") + return original_encode( + payload, "secret-encoding-key", algorithm="HS256", *args, **kwargs + ) + + original_encode = jwt.encode + with mock.patch("jwt.encode", new=encode): + token = github_utils.generate_token() + decoded = jwt.decode(token, "secret-encoding-key", algorithms=["HS256"]) + + delta = datetime.timedelta(minutes=10) + self.assertAlmostEqual(decoded["exp"] - decoded["iat"], delta.total_seconds()) + self.assertLess(decoded["exp"], (datetime.datetime.now() + delta).timestamp()) + + +@mock.patch("httpx.AsyncClient", autospec=True) +@mock.patch("asyncio.sleep", new=mock.AsyncMock(return_value=asyncio.Future)) +@mock.patch("httpx.Response.raise_for_status", new=patched_raise_for_status) +class WaitForTests(unittest.IsolatedAsyncioTestCase): + """Tests the wait_for utility.""" + + async def test_wait_for_successful_run(self, client_mock: mock.Mock): + """Test that the wait_for method handles successfully runs.""" + final_url = "some_url" + str(random.randint(0, 10)) + + client_mock.get.side_effect = responses = [ + httpx.Response(200, json={"status": "queued", "url": ""}), + httpx.Response(200, json={"status": "pending", "url": ""}), + httpx.Response(200, json={ + "status": "completed", + "conclusion": "success", + "url": "", + "artifacts_url": final_url + }) + ] + + result = await github_utils.wait_for_run(client_mock, responses[0].json()) + self.assertEqual(final_url, result) + + async def test_wait_for_failed_run(self, client_mock: mock.Mock): + """Test that the wait_for method handles failed runs.""" + client_mock.get.return_value = httpx.Response(200, json={ + "status": "completed", + "conclusion": "failed", + }) + + with self.assertRaises(github_utils.ActionFailedError): + await github_utils.wait_for_run(client_mock, {"status": "pending", "url": ""}) + + async def test_wait_for_timeout(self, client_mock: mock.Mock): + """Test that the wait_for method quits after a few attempts.""" + client_mock.get.side_effect = responses = [ + httpx.Response(200, json={"status": "pending", "url": ""}) + ] * (github_utils.MAX_POLLS + 5) + + with self.assertRaises(github_utils.RunTimeoutError): + await github_utils.wait_for_run(client_mock, responses[0].json()) + + +async def get_response_authorize( + _: httpx.AsyncClient, request: httpx.Request, **__ +) -> httpx.Response: + """ + Helper method for the authorize tests. + + Requests are intercepted before being sent out, and the appropriate responses are returned. + """ + path = request.url.path + auth = request.headers.get("Authorization") + + if request.method == "GET": + if path == "/app/installations": + if auth == "bearer JWT initial token": + return httpx.Response(200, request=request, json=[{ + "account": {"login": "VALID_OWNER"}, + "access_tokens_url": "https://example.com/ACCESS_TOKEN_URL" + }]) + else: + return httpx.Response( + 401, json={"error": "auth app/installations"}, request=request + ) + + elif path == "/installation/repositories": + if auth == "bearer app access token": + return httpx.Response(200, request=request, json={ + "repositories": [{ + "name": "VALID_REPO" + }] + }) + else: # pragma: no cover + return httpx.Response( + 401, json={"error": "auth installation/repositories"}, request=request + ) + + elif request.method == "POST": + if path == "/ACCESS_TOKEN_URL": + if auth == "bearer JWT initial token": + return httpx.Response(200, request=request, json={"token": "app access token"}) + else: # pragma: no cover + return httpx.Response(401, json={"error": "auth access_token"}, request=request) + + # Reaching this point means something has gone wrong + return httpx.Response(500, request=request) # pragma: no cover + + +@mock.patch("httpx.AsyncClient.send", new=get_response_authorize) +@mock.patch.object(github_utils, "generate_token", new=mock.Mock(return_value="JWT initial token")) +class AuthorizeTests(unittest.IsolatedAsyncioTestCase): + """Test the authorize utility.""" + + async def test_invalid_apps_auth(self): + """Test that an exception is raised if authorization was attempted with an invalid token.""" + with mock.patch.object(github_utils, "generate_token", return_value="Invalid token"): + with self.assertRaises(httpx.HTTPStatusError) as error: + await github_utils.authorize("VALID_OWNER", "VALID_REPO") + + exception: httpx.HTTPStatusError = error.exception + self.assertEqual(401, exception.response.status_code) + self.assertEqual("auth app/installations", exception.response.json()["error"]) + + async def test_missing_repo(self): + """Test that an exception is raised when the selected owner or repo are not available.""" + with self.assertRaises(github_utils.NotFoundError): + await github_utils.authorize("INVALID_OWNER", "VALID_REPO") + with self.assertRaises(github_utils.NotFoundError): + await github_utils.authorize("VALID_OWNER", "INVALID_REPO") + + async def test_valid_authorization(self): + """Test that an accessible repository can be accessed.""" + client = await github_utils.authorize("VALID_OWNER", "VALID_REPO") + self.assertEqual("bearer app access token", client.headers.get("Authorization")) + + +async def get_response_get_artifact(request: httpx.Request, **_) -> httpx.Response: + """ + Helper method for the get_artifact tests. + + Requests are intercepted before being sent out, and the appropriate responses are returned. + """ + path = request.url.path + + if "force_error" in path: + return httpx.Response(404, request=request) + + if request.method == "GET": + if path == "/repos/owner/repo/actions/runs": + return httpx.Response(200, request=request, json={"workflow_runs": [{ + "name": "action_name", + "head_sha": "action_sha" + }]}) + elif path == "/artifact_url": + return httpx.Response(200, request=request, json={"artifacts": [{ + "name": "artifact_name", + "archive_download_url": "artifact_download_url" + }]}) + elif path == "/artifact_download_url": + response = httpx.Response(302, request=request) + response.next_request = httpx.Request("GET", httpx.URL("https://final_download.url")) + return response + + # Reaching this point means something has gone wrong + return httpx.Response(500, request=request) # pragma: no cover + + +class ArtifactFetcherTests(unittest.IsolatedAsyncioTestCase): + """Test the get_artifact utility.""" + + def setUp(self) -> None: + self.call_args = ["owner", "repo", "action_sha", "action_name", "artifact_name"] + self.client = httpx.AsyncClient(base_url="https://example.com") + + self.patchers = [ + mock.patch.object(self.client, "send", new=get_response_get_artifact), + mock.patch.object(github_utils, "authorize", return_value=self.client), + mock.patch.object(github_utils, "wait_for_run", return_value="artifact_url"), + ] + + for patcher in self.patchers: + patcher.start() + + def tearDown(self) -> None: + for patcher in self.patchers: + patcher.stop() + + def test_client_closed_on_errors(self): + """Test that the client is terminated even if an error occurs at some point.""" + self.call_args[0] = "force_error" + with self.assertRaises(httpx.HTTPStatusError): + github_utils.get_artifact(*self.call_args) + self.assertTrue(self.client.is_closed) + + def test_missing(self): + """Test that an exception is raised if the requested artifact was not found.""" + cases = ( + "invalid sha", + "invalid action name", + "invalid artifact name", + ) + for i, name in enumerate(cases, 2): + with self.subTest(f"Test {name} raises an error"): + new_args = self.call_args.copy() + new_args[i] = name + + with self.assertRaises(github_utils.NotFoundError): + github_utils.get_artifact(*new_args) + + def test_valid(self): + """Test that the correct download URL is returned for valid requests.""" + url = github_utils.get_artifact(*self.call_args) + self.assertEqual("https://final_download.url", url) + self.assertTrue(self.client.is_closed) + + +@mock.patch.object(github_utils, "get_artifact") +class GitHubArtifactViewTests(django.test.TestCase): + """Test the GitHub artifact fetch API view.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.kwargs = { + "owner": "test_owner", + "repo": "test_repo", + "sha": "test_sha", + "action_name": "test_action", + "artifact_name": "test_artifact", + } + cls.url = reverse("api:github-artifacts", kwargs=cls.kwargs) + + async def test_successful(self, artifact_mock: mock.Mock): + """Test a proper response is returned with proper input.""" + artifact_mock.return_value = "final download url" + result = self.client.get(self.url) + + self.assertIsInstance(result, rest_framework.response.Response) + self.assertEqual({"url": artifact_mock.return_value}, result.data) + + async def test_failed_fetch(self, artifact_mock: mock.Mock): + """Test that a proper error is returned when the request fails.""" + artifact_mock.side_effect = github_utils.NotFoundError("Test error message") + result = self.client.get(self.url) + + self.assertIsInstance(result, rest_framework.response.Response) + self.assertEqual({ + "error_type": github_utils.NotFoundError.__name__, + "error": "Test error message", + "requested_resource": "/".join(self.kwargs.values()) + }, result.data) diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index 1e564b29..2757f176 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -1,7 +1,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import HealthcheckView, RulesView +from .views import GitHubArtifactsView, HealthcheckView, RulesView from .viewsets import ( AocAccountLinkViewSet, AocCompletionistBlockViewSet, @@ -86,5 +86,10 @@ urlpatterns = ( # from django_hosts.resolvers import reverse path('bot/', include((bot_router.urls, 'api'), namespace='bot')), path('healthcheck', HealthcheckView.as_view(), name='healthcheck'), - path('rules', RulesView.as_view(), name='rules') + path('rules', RulesView.as_view(), name='rules'), + path( + 'github/artifact/////', + GitHubArtifactsView.as_view(), + name="github-artifacts" + ), ) diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 816463f6..ad2d948e 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -1,7 +1,10 @@ from rest_framework.exceptions import ParseError +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from . import github_utils + class HealthcheckView(APIView): """ @@ -152,3 +155,53 @@ class RulesView(APIView): "Do not offer or ask for paid work of any kind." ), ]) + + +class GitHubArtifactsView(APIView): + """ + Provides utilities for interacting with the GitHub API and obtaining action artifacts. + + ## Routes + ### GET /github/artifacts + Returns a download URL for the artifact requested. + + { + 'url': 'https://pipelines.actions.githubusercontent.com/...' + } + + ### Exceptions + In case of an error, the following body will be returned: + + { + "error_type": "", + "error": "", + "requested_resource": "///" + } + + ## Authentication + Does not require any authentication nor permissions. + """ + + authentication_classes = () + permission_classes = () + + def get( + self, + request: Request, + *, + owner: str, + repo: str, + sha: str, + action_name: str, + artifact_name: str + ) -> Response: + """Return a download URL for the requested artifact.""" + try: + url = github_utils.get_artifact(owner, repo, sha, action_name, artifact_name) + return Response({"url": url}) + except github_utils.ArtifactProcessingError as e: + return Response({ + "error_type": e.__class__.__name__, + "error": str(e), + "requested_resource": f"{owner}/{repo}/{sha}/{action_name}/{artifact_name}" + }, status=e.status) diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 03c16f4b..f382b052 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -21,7 +21,6 @@ import environ import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration - env = environ.Env( DEBUG=(bool, False), SITE_DSN=(str, ""), @@ -30,10 +29,19 @@ env = environ.Env( GIT_SHA=(str, 'development'), TIMEOUT_PERIOD=(int, 5), GITHUB_TOKEN=(str, None), + GITHUB_OAUTH_APP_ID=(str, None), + GITHUB_OAUTH_KEY=(str, None), ) GIT_SHA = env("GIT_SHA") +GITHUB_API = "https://api.github.com" GITHUB_TOKEN = env("GITHUB_TOKEN") +GITHUB_OAUTH_APP_ID = env("GITHUB_OAUTH_APP_ID") +GITHUB_OAUTH_KEY = env("GITHUB_OAUTH_KEY") + +if GITHUB_OAUTH_KEY and (oauth_file := Path(GITHUB_OAUTH_KEY)).is_file(): + # Allow the OAuth key to be loaded from a file + GITHUB_OAUTH_KEY = oauth_file.read_text(encoding="utf-8") sentry_sdk.init( dsn=env('SITE_DSN'), diff --git a/pyproject.toml b/pyproject.toml index 467fc8bc..1c24d308 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ markdown = "~=3.3.4" python-frontmatter = "~=1.0" django-prometheus = "~=2.1" django-distill = "~=2.9.0" +PyJWT = {version = "~=2.4.0", extras = ["crypto"]} [tool.poetry.dev-dependencies] coverage = "~=5.0" -- cgit v1.2.3 From 460ccffe266373febcd1676d609d65f03de5a967 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 12 Aug 2022 16:32:59 +0200 Subject: Rename GitHub App Environment Variables Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/github_utils.py | 4 ++-- pydis_site/settings.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) (limited to 'pydis_site/settings.py') diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py index 7d26b147..ad24165d 100644 --- a/pydis_site/apps/api/github_utils.py +++ b/pydis_site/apps/api/github_utils.py @@ -89,9 +89,9 @@ def generate_token() -> str: { "iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()), # Issued at "exp": math.floor((now + datetime.timedelta(minutes=9)).timestamp()), # Expires at - "iss": settings.GITHUB_OAUTH_APP_ID, + "iss": settings.GITHUB_APP_ID, }, - settings.GITHUB_OAUTH_KEY, + settings.GITHUB_APP_KEY, algorithm="RS256" ) diff --git a/pydis_site/settings.py b/pydis_site/settings.py index f382b052..bbf1d3aa 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -29,19 +29,19 @@ env = environ.Env( GIT_SHA=(str, 'development'), TIMEOUT_PERIOD=(int, 5), GITHUB_TOKEN=(str, None), - GITHUB_OAUTH_APP_ID=(str, None), - GITHUB_OAUTH_KEY=(str, None), + GITHUB_APP_ID=(str, None), + GITHUB_APP_KEY=(str, None), ) GIT_SHA = env("GIT_SHA") GITHUB_API = "https://api.github.com" GITHUB_TOKEN = env("GITHUB_TOKEN") -GITHUB_OAUTH_APP_ID = env("GITHUB_OAUTH_APP_ID") -GITHUB_OAUTH_KEY = env("GITHUB_OAUTH_KEY") +GITHUB_APP_ID = env("GITHUB_APP_ID") +GITHUB_APP_KEY = env("GITHUB_APP_KEY") -if GITHUB_OAUTH_KEY and (oauth_file := Path(GITHUB_OAUTH_KEY)).is_file(): +if GITHUB_APP_KEY and (key_file := Path(GITHUB_APP_KEY)).is_file(): # Allow the OAuth key to be loaded from a file - GITHUB_OAUTH_KEY = oauth_file.read_text(encoding="utf-8") + GITHUB_APP_KEY = key_file.read_text(encoding="utf-8") sentry_sdk.init( dsn=env('SITE_DSN'), -- cgit v1.2.3 From edcee093449fce833116ae3dbb15d6ccf3797652 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 13 Aug 2022 01:53:19 +0200 Subject: Add Setting For Static Builds Adds an explicit setting for static builds instead of relying on the environment variable. Signed-off-by: Hassan Abouelela --- pydis_site/apps/home/views/home.py | 8 +++----- pydis_site/apps/redirect/urls.py | 2 +- pydis_site/settings.py | 8 +++++--- pydis_site/urls.py | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) (limited to 'pydis_site/settings.py') diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py index 9bb1f8fd..8a165682 100644 --- a/pydis_site/apps/home/views/home.py +++ b/pydis_site/apps/home/views/home.py @@ -32,9 +32,7 @@ class HomeView(View): def __init__(self): """Clean up stale RepositoryMetadata.""" - self._static_build = settings.env("STATIC_BUILD") - - if not self._static_build: + if not settings.STATIC_BUILD: RepositoryMetadata.objects.exclude(repo_name__in=self.repos).delete() # If no token is defined (for example in local development), then @@ -94,7 +92,7 @@ class HomeView(View): def _get_repo_data(self) -> List[RepositoryMetadata]: """Build a list of RepositoryMetadata objects that we can use to populate the front page.""" # First off, load the timestamp of the least recently updated entry. - if self._static_build: + if settings.STATIC_BUILD: last_update = None else: last_update = ( @@ -121,7 +119,7 @@ class HomeView(View): for api_data in api_repositories.values() ] - if settings.env("STATIC_BUILD"): + if settings.STATIC_BUILD: return data else: return RepositoryMetadata.objects.bulk_create(data) diff --git a/pydis_site/apps/redirect/urls.py b/pydis_site/apps/redirect/urls.py index f7ddf45b..ed1564b0 100644 --- a/pydis_site/apps/redirect/urls.py +++ b/pydis_site/apps/redirect/urls.py @@ -31,7 +31,7 @@ class Redirect: def map_redirect(name: str, data: Redirect) -> list[URLPattern]: """Return a pattern using the Redirects app, or a static HTML redirect for static builds.""" - if not settings.env("STATIC_BUILD"): + if not settings.STATIC_BUILD: # Normal dynamic redirect return [path( data.original_path, diff --git a/pydis_site/settings.py b/pydis_site/settings.py index bbf1d3aa..315ea737 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -95,11 +95,13 @@ else: ) SECRET_KEY = env('SECRET_KEY') +STATIC_BUILD: bool = env("STATIC_BUILD") + # Application definition NON_STATIC_APPS = [ 'pydis_site.apps.api', 'pydis_site.apps.staff', -] if not env("STATIC_BUILD") else [] +] if not STATIC_BUILD else [] INSTALLED_APPS = [ *NON_STATIC_APPS, @@ -130,7 +132,7 @@ if not env("BUILDING_DOCKER"): NON_STATIC_MIDDLEWARE = [ 'django_prometheus.middleware.PrometheusBeforeMiddleware', -] if not env("STATIC_BUILD") else [] +] if not STATIC_BUILD else [] # Ensure that Prometheus middlewares are first and last here. MIDDLEWARE = [ @@ -175,7 +177,7 @@ WSGI_APPLICATION = 'pydis_site.wsgi.application' DATABASES = { 'default': env.db(), 'metricity': env.db('METRICITY_DB_URL'), -} if not env("STATIC_BUILD") else {} +} if not STATIC_BUILD else {} # Password validation # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators diff --git a/pydis_site/urls.py b/pydis_site/urls.py index 6cd31f26..0f2f6aeb 100644 --- a/pydis_site/urls.py +++ b/pydis_site/urls.py @@ -12,7 +12,7 @@ NON_STATIC_PATTERNS = [ path('pydis-api/', include('pydis_site.apps.api.urls', namespace='internal_api')), path('', include('django_prometheus.urls')), -] if not settings.env("STATIC_BUILD") else [] +] if not settings.STATIC_BUILD else [] urlpatterns = ( @@ -29,7 +29,7 @@ urlpatterns = ( ) -if not settings.env("STATIC_BUILD"): +if not settings.STATIC_BUILD: urlpatterns += ( path('staff/', include('pydis_site.apps.staff.urls', namespace='staff')), ) -- cgit v1.2.3 From 5dfe019745b53ceb8ce37f0db937d6e2a302f6d7 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 16 Aug 2022 18:58:29 +0400 Subject: Move GitHub strptime Format To Settings Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/github_utils.py | 4 +--- pydis_site/settings.py | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'pydis_site/settings.py') diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py index 5d7bcdc3..e9d7347b 100644 --- a/pydis_site/apps/api/github_utils.py +++ b/pydis_site/apps/api/github_utils.py @@ -11,8 +11,6 @@ from pydis_site import settings MAX_RUN_TIME = datetime.timedelta(minutes=10) """The maximum time allowed before an action is declared timed out.""" -ISO_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ" -"""The datetime string format GitHub uses.""" class ArtifactProcessingError(Exception): @@ -147,7 +145,7 @@ def authorize(owner: str, repo: str) -> httpx.Client: def check_run_status(run: WorkflowRun) -> str: """Check if the provided run has been completed, otherwise raise an exception.""" - created_at = datetime.datetime.strptime(run.created_at, ISO_FORMAT_STRING) + created_at = datetime.datetime.strptime(run.created_at, settings.GITHUB_TIMESTAMP_FORMAT) run_time = datetime.datetime.utcnow() - created_at if run.status != "completed": diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 315ea737..9fbd0273 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -38,6 +38,8 @@ GITHUB_API = "https://api.github.com" GITHUB_TOKEN = env("GITHUB_TOKEN") GITHUB_APP_ID = env("GITHUB_APP_ID") GITHUB_APP_KEY = env("GITHUB_APP_KEY") +GITHUB_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +"""The datetime string format GitHub uses.""" if GITHUB_APP_KEY and (key_file := Path(GITHUB_APP_KEY)).is_file(): # Allow the OAuth key to be loaded from a file -- cgit v1.2.3 From cb40babfb49fb3ec7e0a5c1ba4c68d3cce4df91a Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 19 Aug 2022 00:46:39 +0400 Subject: Fix Django Deprecation Warnings Removes a few features which were deprecated in django 4.0. Running with warnings enabled shows no other errors. USE_L10N was deprecated, and is now enabled by default. In future versions of django, it'll be impossible to turn localization off. Explicitly defining the custom_app_config for the API app is no longer necessary as django can pick it up on its own. Signed-off-by: Hassan Abouelela --- pydis_site/apps/api/__init__.py | 1 - pydis_site/settings.py | 1 - 2 files changed, 2 deletions(-) (limited to 'pydis_site/settings.py') 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/settings.py b/pydis_site/settings.py index bbf1d3aa..e08a2630 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -200,7 +200,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) -- cgit v1.2.3 From cd815d3e928e8f83e74f10389110ddb615199342 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sun, 21 Aug 2022 22:56:16 +0400 Subject: Fix Static Build Remove all unnecessary middleware and return a proper response for redirects to fix static builds. Signed-off-by: Hassan Abouelela --- pydis_site/apps/redirect/urls.py | 5 ++-- pydis_site/settings.py | 55 ++++++++++++++++++++++------------------ 2 files changed, 33 insertions(+), 27 deletions(-) (limited to 'pydis_site/settings.py') diff --git a/pydis_site/apps/redirect/urls.py b/pydis_site/apps/redirect/urls.py index f7ddf45b..f86fe665 100644 --- a/pydis_site/apps/redirect/urls.py +++ b/pydis_site/apps/redirect/urls.py @@ -3,6 +3,7 @@ import re import yaml from django import conf +from django.http import HttpResponse from django.urls import URLPattern, path from django_distill import distill_path @@ -53,7 +54,7 @@ def map_redirect(name: str, data: Redirect) -> list[URLPattern]: class RedirectFunc: def __init__(self, new_url: str, _name: str): - self.result = REDIRECT_TEMPLATE.format(url=new_url) + self.result = HttpResponse(REDIRECT_TEMPLATE.format(url=new_url)) self.__qualname__ = _name def __call__(self, *args, **kwargs): @@ -95,7 +96,7 @@ def map_redirect(name: str, data: Redirect) -> list[URLPattern]: return [distill_path( data.original_path, - lambda *args: REDIRECT_TEMPLATE.format(url=new_redirect), + lambda *args: HttpResponse(REDIRECT_TEMPLATE.format(url=new_redirect)), name=name, )] diff --git a/pydis_site/settings.py b/pydis_site/settings.py index e08a2630..cb05956b 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -43,12 +43,13 @@ 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") -sentry_sdk.init( - dsn=env('SITE_DSN'), - integrations=[DjangoIntegration()], - send_default_pii=True, - release=f"site@{GIT_SHA}" -) +if not env("STATIC_BUILD"): + sentry_sdk.init( + dsn=env('SITE_DSN'), + integrations=[DjangoIntegration()], + send_default_pii=True, + release=f"site@{GIT_SHA}" + ) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -128,25 +129,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 env("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' -- cgit v1.2.3 From a34ebfa67838acac9f2651d7eba7a7472a4bd4ec Mon Sep 17 00:00:00 2001 From: hedy Date: Sat, 29 Oct 2022 10:37:45 +0800 Subject: Use content articles source url in django.conf.settings --- pydis_site/apps/content/templatetags/page_src.py | 3 ++- pydis_site/settings.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) (limited to 'pydis_site/settings.py') diff --git a/pydis_site/apps/content/templatetags/page_src.py b/pydis_site/apps/content/templatetags/page_src.py index 33f24b82..3a8f1e8b 100644 --- a/pydis_site/apps/content/templatetags/page_src.py +++ b/pydis_site/apps/content/templatetags/page_src.py @@ -1,4 +1,5 @@ from django import template +from django.conf import settings register = template.Library() @@ -16,7 +17,7 @@ def page_src_url(request_path: str) -> str: """ src_url = request_path.replace( "/pages/", - "https://github.com/python-discord/site/tree/main/pydis_site/apps/content/resources/", + settings.CONTENT_SRC_URL, ) src_url = src_url[:-1] + ".md" return src_url diff --git a/pydis_site/settings.py b/pydis_site/settings.py index e9e0ba67..9da9a156 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -332,3 +332,8 @@ REDIRECTIONS_PATH = Path(BASE_DIR, "pydis_site", "apps", "redirect", "redirects. # How long to wait for synchronous requests before timing out TIMEOUT_PERIOD = env("TIMEOUT_PERIOD") + +# Source files url for 'Edit on GitHub' link on content articles +CONTENT_SRC_URL = ( + "https://github.com/python-discord/site/tree/main/pydis_site/apps/content/resources/" +) -- cgit v1.2.3 From 11fc279717089a8774c6f58fdceec29d243a89d1 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 11 Dec 2023 15:56:26 +0000 Subject: Enable sentry logging integration --- pydis_site/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'pydis_site/settings.py') diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 9da9a156..61713c5c 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -10,6 +10,7 @@ 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 @@ -19,6 +20,7 @@ from socket import gethostbyname, gethostname import environ import sentry_sdk +from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.django import DjangoIntegration env = environ.Env( @@ -50,7 +52,7 @@ if GITHUB_APP_KEY and (key_file := Path(GITHUB_APP_KEY)).is_file(): if not STATIC_BUILD: sentry_sdk.init( dsn=env('SITE_DSN'), - integrations=[DjangoIntegration()], + integrations=[DjangoIntegration(), LoggingIntegration(level=logging.DEBUG, event_level=logging.ERROR)], send_default_pii=True, release=f"site@{GIT_SHA}" ) -- cgit v1.2.3 From 6ec6fffc9283b1051b4f90a84cd85aea8e457609 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 11 Dec 2023 17:00:45 +0000 Subject: Enable Sentry profile sampling --- pydis_site/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'pydis_site/settings.py') diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 61713c5c..ec8efac3 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -54,7 +54,8 @@ if not STATIC_BUILD: dsn=env('SITE_DSN'), integrations=[DjangoIntegration(), LoggingIntegration(level=logging.DEBUG, event_level=logging.ERROR)], send_default_pii=True, - release=f"site@{GIT_SHA}" + release=f"site@{GIT_SHA}", + profiles_sample_rate=0.5, ) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) -- cgit v1.2.3 From 6a0efbb3ae7c321421a30de726986522a79e0624 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 12 Dec 2023 15:33:55 +0000 Subject: Enable tracing and include source for slow queries --- pydis_site/settings.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'pydis_site/settings.py') diff --git a/pydis_site/settings.py b/pydis_site/settings.py index ec8efac3..5a6edfdf 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -55,7 +55,10 @@ if not STATIC_BUILD: integrations=[DjangoIntegration(), LoggingIntegration(level=logging.DEBUG, event_level=logging.ERROR)], send_default_pii=True, release=f"site@{GIT_SHA}", - profiles_sample_rate=0.5, + 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, ...) -- cgit v1.2.3