From 3fcd22aa3800eaed982041055fc922c091404cfa Mon Sep 17 00:00:00 2001 From: Artur Czepiel Date: Sun, 12 Apr 2026 21:25:19 +0200 Subject: [PATCH 1/4] feat(login): add login with google from @europython.eu domain --- intbot/.env.example | 4 + intbot/core/auth.py | 25 +++ intbot/core/views.py | 11 +- intbot/intbot/settings.py | 34 ++++ intbot/intbot/urls.py | 6 +- intbot/templates/account/login.html | 45 +++++ intbot/templates/no_access.html | 33 ++++ .../socialaccount/authentication_error.html | 35 ++++ intbot/tests/test_views.py | 30 +++- pyproject.toml | 1 + uv.lock | 167 +++++++++++++++++- 11 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 intbot/core/auth.py create mode 100644 intbot/templates/account/login.html create mode 100644 intbot/templates/no_access.html create mode 100644 intbot/templates/socialaccount/authentication_error.html diff --git a/intbot/.env.example b/intbot/.env.example index b9555ec..4201ee3 100644 --- a/intbot/.env.example +++ b/intbot/.env.example @@ -3,6 +3,10 @@ DISCORD_BOT_TOKEN='asdf' DISCORD_TEST_CHANNEL_ID="123123123123123123123123" DISCORD_TEST_CHANNEL_NAME="#test-channel" +# Google OAuth +GOOGLE_OAUTH_CLIENT_ID="" +GOOGLE_OAUTH_CLIENT_SECRET="" + # Github GITHUB_API_TOKEN="github-api-token" GITHUB_WEBHOOK_SECRET_TOKEN="github-webhook-secret-token" diff --git a/intbot/core/auth.py b/intbot/core/auth.py new file mode 100644 index 0000000..d34d14c --- /dev/null +++ b/intbot/core/auth.py @@ -0,0 +1,25 @@ +from functools import wraps + +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter # type: ignore[import-untyped] +from django.http import HttpRequest +from django.shortcuts import redirect + + +class EuroPythonSocialAccountAdapter(DefaultSocialAccountAdapter): + def is_open_for_signup( + self, request: HttpRequest, sociallogin: object + ) -> bool: + email = sociallogin.user.email # type: ignore[attr-defined] + return email.endswith("@europython.eu") + + +def staff_required(view_func): # type: ignore[no-untyped-def] + @wraps(view_func) + def wrapper(request: HttpRequest, *args, **kwargs): # type: ignore[no-untyped-def] + if not request.user.is_authenticated: + return redirect(f"/accounts/login/?next={request.path}") + if not request.user.is_staff: + return redirect("/no-access/") + return view_func(request, *args, **kwargs) + + return wrapper diff --git a/intbot/core/views.py b/intbot/core/views.py index 8408d0c..d7ca573 100644 --- a/intbot/core/views.py +++ b/intbot/core/views.py @@ -4,13 +4,18 @@ latest_flat_submissions_data, piechart_submissions_by_state, ) +from core.auth import staff_required from django.conf import settings -from django.contrib.auth.decorators import login_required +from django.http import HttpRequest from django.template.response import TemplateResponse from django.utils import timezone from django.utils.safestring import mark_safe +def no_access(request: HttpRequest) -> TemplateResponse: + return TemplateResponse(request, "no_access.html", status=403) + + def days_until(request): delta = settings.CONFERENCE_START - timezone.now() @@ -23,7 +28,7 @@ def days_until(request): ) -@login_required +@staff_required def products(request): """ For now this is just an example of the implementation. @@ -45,7 +50,7 @@ def products(request): ) -@login_required +@staff_required def submissions(request): """ Show some basic aggregation of submissions data diff --git a/intbot/intbot/settings.py b/intbot/intbot/settings.py index b5638de..3149d81 100644 --- a/intbot/intbot/settings.py +++ b/intbot/intbot/settings.py @@ -32,6 +32,10 @@ "django_extensions", "django_tasks", "django_tasks.backends.database", + "allauth", + "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.google", # Project apps "core", ] @@ -46,6 +50,12 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "allauth.account.middleware.AccountMiddleware", +] + +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", ] ROOT_URLCONF = "intbot.urls" @@ -213,6 +223,27 @@ def get(name) -> str: # Pretix PRETIX_API_TOKEN = get("PRETIX_API_TOKEN") +# Google OAuth +GOOGLE_OAUTH_CLIENT_ID = get("GOOGLE_OAUTH_CLIENT_ID") +GOOGLE_OAUTH_CLIENT_SECRET = get("GOOGLE_OAUTH_CLIENT_SECRET") + +# Allauth +LOGIN_REDIRECT_URL = "/" +SOCIALACCOUNT_ONLY = True +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_EMAIL_VERIFICATION = "none" +SOCIALACCOUNT_ADAPTER = "core.auth.EuroPythonSocialAccountAdapter" +SOCIALACCOUNT_PROVIDERS = { + "google": { + "APP": { + "client_id": GOOGLE_OAUTH_CLIENT_ID, + "secret": GOOGLE_OAUTH_CLIENT_SECRET, + }, + "SCOPE": ["profile", "email"], + "AUTH_PARAMS": {"access_type": "online"}, + }, +} + if DJANGO_ENV == "dev": DEBUG = True @@ -300,6 +331,9 @@ def get(name) -> str: PRETALX_API_TOKEN = "Test-Pretalx-API-token" + GOOGLE_OAUTH_CLIENT_ID = "test-google-client-id" + GOOGLE_OAUTH_CLIENT_SECRET = "test-google-client-secret" + elif DJANGO_ENV == "local_container": DEBUG = False diff --git a/intbot/intbot/urls.py b/intbot/intbot/urls.py index df7e719..1346458 100644 --- a/intbot/intbot/urls.py +++ b/intbot/intbot/urls.py @@ -4,12 +4,13 @@ internal_webhook_endpoint, zammad_webhook_endpoint, ) -from core.views import days_until, products, submissions +from core.views import days_until, no_access, products, submissions from django.contrib import admin -from django.urls import path +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), + path("accounts/", include("allauth.urls")), path("", index), # Webhooks path("webhook/internal/", internal_webhook_endpoint), @@ -19,4 +20,5 @@ path("days-until/", days_until), path("products/", products), path("submissions/", submissions), + path("no-access/", no_access), ] diff --git a/intbot/templates/account/login.html b/intbot/templates/account/login.html new file mode 100644 index 0000000..7b11d47 --- /dev/null +++ b/intbot/templates/account/login.html @@ -0,0 +1,45 @@ +{% load socialaccount %} + + + + Sign In - EuroPython Internal + + + +
+

EuroPython Internal

+

Sign in with your @europython.eu account

+ Sign in with Google +
+ + diff --git a/intbot/templates/no_access.html b/intbot/templates/no_access.html new file mode 100644 index 0000000..b4e9383 --- /dev/null +++ b/intbot/templates/no_access.html @@ -0,0 +1,33 @@ + + + + No Access - EuroPython Internal + + + +
+

Access Denied

+

You are logged in but do not have access to this resource yet.

+

Please contact an admin to get your account promoted.

+
+ + diff --git a/intbot/templates/socialaccount/authentication_error.html b/intbot/templates/socialaccount/authentication_error.html new file mode 100644 index 0000000..0bdb07c --- /dev/null +++ b/intbot/templates/socialaccount/authentication_error.html @@ -0,0 +1,35 @@ + + + + Authentication Error - EuroPython Internal + + + +
+

Authentication Error

+

Sign-in is restricted to @europython.eu accounts only.

+

If you believe this is an error, please contact an admin.

+

Try again

+
+ + diff --git a/intbot/tests/test_views.py b/intbot/tests/test_views.py index 4df66b3..bbce3e1 100644 --- a/intbot/tests/test_views.py +++ b/intbot/tests/test_views.py @@ -1,4 +1,6 @@ +import pytest from core.models import PretalxData, PretixData +from django.contrib.auth.models import User from pytest_django.asserts import assertRedirects, assertTemplateUsed @@ -9,15 +11,26 @@ def test_days_until_view(client): assertTemplateUsed(response, "days_until.html") +@pytest.mark.django_db class TestPorductsView: def test_products_view_requires_login(self, client): response = client.get("/products/") assertRedirects( - response, "/accounts/login/?next=/products/", target_status_code=404 + response, + "/accounts/login/?next=/products/", + fetch_redirect_response=False, ) assert response.status_code == 302 + def test_products_non_staff_redirects_to_no_access(self, client): + user = User.objects.create_user(username="regular", password="pass") + client.force_login(user) + + response = client.get("/products/") + + assertRedirects(response, "/no-access/", target_status_code=403) + def test_products_sanity_check(self, admin_client): PretixData.objects.create( resource=PretixData.PretixResources.products, content=[] @@ -29,16 +42,25 @@ def test_products_sanity_check(self, admin_client): assertTemplateUsed(response, "table.html") +@pytest.mark.django_db class TestSubmissionsView: def test_submissions_view_requires_login(self, client): response = client.get("/submissions/") - # 404 because we don't have a user login view yet - we can use admin - # for that assertRedirects( - response, "/accounts/login/?next=/submissions/", target_status_code=404 + response, + "/accounts/login/?next=/submissions/", + fetch_redirect_response=False, ) + def test_submissions_non_staff_redirects_to_no_access(self, client): + user = User.objects.create_user(username="regular", password="pass") + client.force_login(user) + + response = client.get("/submissions/") + + assertRedirects(response, "/no-access/", target_status_code=403) + def test_submissions_basic_sanity_check(self, admin_client): """ This test won't work without data, because it's running group_by and diff --git a/pyproject.toml b/pyproject.toml index 056fcbb..aa66381 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "plotly[express]>=6.0.1", "kaleido==0.2.0", "plotly-stubs>=0.0.5", + "django-allauth[socialaccount]>=65.3.0", ] [tool.uv] diff --git a/uv.lock b/uv.lock index 5da66ce..f2b3845 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = "==3.12.*" [options] -exclude-newer = "2026-01-05T22:14:18.286185748Z" +exclude-newer = "2026-04-05T19:17:47.610747818Z" exclude-newer-span = "P1W" [[package]] @@ -120,6 +120,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -151,6 +199,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, +] + [[package]] name = "decorator" version = "5.2.1" @@ -186,6 +273,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/ae/f19e24789a5ad852670d6885f5480f5e5895576945fcc01817dfd9bc002a/django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d", size = 8339181, upload-time = "2025-12-03T16:26:16.231Z" }, ] +[[package]] +name = "django-allauth" +version = "65.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/91/826ec2c0e81b36ed392995b23604cfb3bb993b6ee66017898c25d2fc33f0/django_allauth-65.15.1.tar.gz", hash = "sha256:94c5b9df0c3458019613983ba9e938518acf5c2e4cc6e0f262fc3684ffe45a95", size = 2216647, upload-time = "2026-04-02T09:18:57.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/20/a6cfe9d5b03bbc76af92060c86682c49df1b8a7a924179da3d20de71dd3a/django_allauth-65.15.1-py3-none-any.whl", hash = "sha256:c32a1031f5601cb66b0aba276fb482d5d1460045a8094795a4db2af0231ee432", size = 2023723, upload-time = "2026-04-02T09:18:33.177Z" }, +] + +[package.optional-dependencies] +socialaccount = [ + { name = "oauthlib" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] + [[package]] name = "django-extensions" version = "4.1" @@ -373,6 +480,7 @@ source = { virtual = "." } dependencies = [ { name = "discord-py" }, { name = "django" }, + { name = "django-allauth", extra = ["socialaccount"] }, { name = "django-extensions" }, { name = "django-stubs" }, { name = "django-tasks" }, @@ -402,6 +510,7 @@ dependencies = [ requires-dist = [ { name = "discord-py", specifier = ">=2.4.0" }, { name = "django", specifier = ">=6.0" }, + { name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.3.0" }, { name = "django-extensions", specifier = ">=3.2.3" }, { name = "django-stubs", specifier = ">=5.1.1" }, { name = "django-tasks", specifier = ">=0.6.1" }, @@ -601,6 +710,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/a6/aae5cc2ca78c45e64b9ef22f089141d661516856cf7c8a54ba434576900d/numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013", size = 10194667, upload-time = "2025-11-16T22:50:16.16Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -782,6 +900,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -835,6 +962,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyreadline3" version = "3.5.4" @@ -932,6 +1073,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + [[package]] name = "respx" version = "0.22.0" @@ -1050,6 +1206,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "wcwidth" version = "0.2.14" From c876070f2180ba28b2451c4bac7c7944b8fdc2f4 Mon Sep 17 00:00:00 2001 From: Artur Czepiel Date: Sun, 12 Apr 2026 21:39:29 +0200 Subject: [PATCH 2/4] add some docs and more tests --- docs/google_oauth.md | 50 +++++++++++++++++++++++++++++++++++ intbot/core/auth.py | 5 ++-- intbot/tests/test_auth.py | 55 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 docs/google_oauth.md create mode 100644 intbot/tests/test_auth.py diff --git a/docs/google_oauth.md b/docs/google_oauth.md new file mode 100644 index 0000000..0f2f6ca --- /dev/null +++ b/docs/google_oauth.md @@ -0,0 +1,50 @@ +# Google OAuth Login + +Users log in with their `@europython.eu` Google Workspace accounts via [django-allauth](https://docs.allauth.org/). + +Only `@europython.eu` emails are allowed — other domains see a friendly error page. On first login a Django user is created with `is_staff=False`, so they can log in but can't access protected pages until an admin promotes them in Django admin. + +Protected views use a `@staff_required` decorator (`core/auth.py`) that checks both authentication and staff status. + +## Setup + +### Production + +1. In [Google Cloud Console](https://console.cloud.google.com/), create an OAuth client ID (Web application) with redirect URI: + ``` + https://internal.europython.eu/accounts/google/login/callback/ + ``` +2. Set env vars on the server: + ``` + GOOGLE_OAUTH_CLIENT_ID= + GOOGLE_OAUTH_CLIENT_SECRET= + ``` +3. Deploy and run migrations (`make deploy/app` handles this) + +### Local development + +Create a separate OAuth client with redirect URI `http://localhost:4672/accounts/google/login/callback/` and add credentials to `intbot/.env`. Then run `make migrate`. + +You can also skip this entirely — create users via Django admin and use `force_login()` in tests. + +## Granting access to users + +1. Go to Django admin (`/admin/` > **Users**) +2. Find the user and set **Staff status** to checked +3. Save — the user can now access protected pages + +## Files + +- `core/auth.py` — `EuroPythonSocialAccountAdapter` (domain restriction) and `staff_required` decorator +- `templates/account/login.html` — login page with Google button +- `templates/no_access.html` — shown to logged-in non-staff users +- `templates/socialaccount/authentication_error.html` — shown for non-europython.eu emails + +## Settings + +Allauth config in `intbot/settings.py`: + +- `SOCIALACCOUNT_ONLY = True` — only social login, no username/password +- `ACCOUNT_EMAIL_VERIFICATION = "none"` — Google already verifies emails +- `SOCIALACCOUNT_ADAPTER` — points to our adapter that restricts to `@europython.eu` +- Google credentials configured via env vars, no `SocialApp` database entries needed diff --git a/intbot/core/auth.py b/intbot/core/auth.py index d34d14c..6880580 100644 --- a/intbot/core/auth.py +++ b/intbot/core/auth.py @@ -1,6 +1,7 @@ from functools import wraps from allauth.socialaccount.adapter import DefaultSocialAccountAdapter # type: ignore[import-untyped] +from django.contrib.auth.decorators import login_required from django.http import HttpRequest from django.shortcuts import redirect @@ -16,10 +17,8 @@ def is_open_for_signup( def staff_required(view_func): # type: ignore[no-untyped-def] @wraps(view_func) def wrapper(request: HttpRequest, *args, **kwargs): # type: ignore[no-untyped-def] - if not request.user.is_authenticated: - return redirect(f"/accounts/login/?next={request.path}") if not request.user.is_staff: return redirect("/no-access/") return view_func(request, *args, **kwargs) - return wrapper + return login_required(wrapper, login_url="/accounts/login/") diff --git a/intbot/tests/test_auth.py b/intbot/tests/test_auth.py new file mode 100644 index 0000000..9412663 --- /dev/null +++ b/intbot/tests/test_auth.py @@ -0,0 +1,55 @@ +from unittest.mock import Mock + +import pytest +from core.auth import EuroPythonSocialAccountAdapter +from django.test import RequestFactory + + +class TestEuroPythonSocialAccountAdapter: + def _make_sociallogin(self, email: str) -> Mock: + sociallogin = Mock() + sociallogin.user.email = email + return sociallogin + + def test_allows_europython_eu_email(self): + adapter = EuroPythonSocialAccountAdapter() + request = RequestFactory().get("/") + sociallogin = self._make_sociallogin("user@europython.eu") + + assert adapter.is_open_for_signup(request, sociallogin) is True + + def test_rejects_other_domain(self): + adapter = EuroPythonSocialAccountAdapter() + request = RequestFactory().get("/") + sociallogin = self._make_sociallogin("user@gmail.com") + + assert adapter.is_open_for_signup(request, sociallogin) is False + + def test_rejects_similar_domain(self): + adapter = EuroPythonSocialAccountAdapter() + request = RequestFactory().get("/") + sociallogin = self._make_sociallogin("user@noteuropython.eu") + + assert adapter.is_open_for_signup(request, sociallogin) is False + + +@pytest.mark.django_db +class TestNoAccessView: + def test_no_access_returns_403(self, client): + response = client.get("/no-access/") + + assert response.status_code == 403 + + def test_no_access_uses_template(self, client): + response = client.get("/no-access/") + + assert "no_access.html" in [t.name for t in response.templates] + + +@pytest.mark.django_db +class TestLoginPage: + def test_login_page_renders(self, client): + response = client.get("/accounts/login/") + + assert response.status_code == 200 + assert b"Sign in with Google" in response.content From dda812152e34b6fad314241884cda030af64d6c7 Mon Sep 17 00:00:00 2001 From: Artur Czepiel Date: Sun, 12 Apr 2026 21:42:35 +0200 Subject: [PATCH 3/4] make format --- intbot/core/analysis/products.py | 18 ++++++++++-------- intbot/core/analysis/submissions.py | 8 ++++++-- intbot/core/auth.py | 4 +--- intbot/core/bot/config.py | 2 ++ intbot/core/bot/scheduled_messages.py | 6 +++--- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/intbot/core/analysis/products.py b/intbot/core/analysis/products.py index 7a43550..40803f5 100644 --- a/intbot/core/analysis/products.py +++ b/intbot/core/analysis/products.py @@ -106,14 +106,16 @@ def flat_product_data(products: list[Product]) -> pl.DataFrame: ) ) - schema = pl.Schema({ - "product_id": pl.Int64(), - "variation_id": pl.Int64(), - "product_name": pl.String(), - "type": pl.String(), - "variant": pl.String(), - "price": pl.Decimal(precision=10, scale=2), - }) + schema = pl.Schema( + { + "product_id": pl.Int64(), + "variation_id": pl.Int64(), + "product_name": pl.String(), + "type": pl.String(), + "variant": pl.String(), + "price": pl.Decimal(precision=10, scale=2), + } + ) return pl.DataFrame(rows, schema=schema) diff --git a/intbot/core/analysis/submissions.py b/intbot/core/analysis/submissions.py index c8802e1..9b40ad2 100644 --- a/intbot/core/analysis/submissions.py +++ b/intbot/core/analysis/submissions.py @@ -57,10 +57,14 @@ def extract_answers(cls, values): # submission questions. is_submission_question = answer["submission"] is not None - if is_submission_question and cls.matches_question(answer, cls.Questions.level): + if is_submission_question and cls.matches_question( + answer, cls.Questions.level + ): values["level"] = answer["answer"] - if is_submission_question and cls.matches_question(answer, cls.Questions.outline): + if is_submission_question and cls.matches_question( + answer, cls.Questions.outline + ): values["outline"] = answer["answer"] return values diff --git a/intbot/core/auth.py b/intbot/core/auth.py index 6880580..dc63636 100644 --- a/intbot/core/auth.py +++ b/intbot/core/auth.py @@ -7,9 +7,7 @@ class EuroPythonSocialAccountAdapter(DefaultSocialAccountAdapter): - def is_open_for_signup( - self, request: HttpRequest, sociallogin: object - ) -> bool: + def is_open_for_signup(self, request: HttpRequest, sociallogin: object) -> bool: email = sociallogin.user.email # type: ignore[attr-defined] return email.endswith("@europython.eu") diff --git a/intbot/core/bot/config.py b/intbot/core/bot/config.py index af1e4af..a23e07f 100644 --- a/intbot/core/bot/config.py +++ b/intbot/core/bot/config.py @@ -1,8 +1,10 @@ """ Configuration for all things discord related """ + from django.conf import settings + class Roles: # We keep this statically defined, because we want to use it in templates # for scheduled messages, and we want to make the scheduling available diff --git a/intbot/core/bot/scheduled_messages.py b/intbot/core/bot/scheduled_messages.py index a810c2d..39de1a6 100644 --- a/intbot/core/bot/scheduled_messages.py +++ b/intbot/core/bot/scheduled_messages.py @@ -18,15 +18,15 @@ def standup_message_factory() -> DiscordMessage: f"(2) What are you planning to work on this week\n" f"(3) Are there any blockers or where could you use some help?" ) - + # Using the test channel for now - replace with appropriate channel later channel = Channels.standup_channel - + return DiscordMessage( channel_id=channel.channel_id, channel_name=channel.channel_name, content=content, - sent_at=None + sent_at=None, ) From 5ed748313755ede88fcfdb36660e85e73e6efc6a Mon Sep 17 00:00:00 2001 From: Artur Czepiel Date: Mon, 13 Apr 2026 14:56:37 +0200 Subject: [PATCH 4/4] clarify the docs about force_login --- docs/google_oauth.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/google_oauth.md b/docs/google_oauth.md index 0f2f6ca..f7058c7 100644 --- a/docs/google_oauth.md +++ b/docs/google_oauth.md @@ -25,7 +25,19 @@ Protected views use a `@staff_required` decorator (`core/auth.py`) that checks b Create a separate OAuth client with redirect URI `http://localhost:4672/accounts/google/login/callback/` and add credentials to `intbot/.env`. Then run `make migrate`. -You can also skip this entirely — create users via Django admin and use `force_login()` in tests. +You can also skip this entirely — for daily usage create users via Django admin (`/admin/`), and in tests use `force_login()`: + +```python +def test_products_page(self, client): + user = User.objects.create_user(username="test", is_staff=True) + client.force_login(user) + + response = client.get("/products/") + + assert response.status_code == 200 +``` + +Use `is_staff=True` to access `@staff_required` views, or omit it to test the non-staff redirect to `/no-access/`. ## Granting access to users