Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/google_oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 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=<your-client-id>
GOOGLE_OAUTH_CLIENT_SECRET=<your-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 — 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

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
4 changes: 4 additions & 0 deletions intbot/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
18 changes: 10 additions & 8 deletions intbot/core/analysis/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
8 changes: 6 additions & 2 deletions intbot/core/analysis/submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions intbot/core/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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


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_staff:
return redirect("/no-access/")
return view_func(request, *args, **kwargs)

return login_required(wrapper, login_url="/accounts/login/")
2 changes: 2 additions & 0 deletions intbot/core/bot/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions intbot/core/bot/scheduled_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
11 changes: 8 additions & 3 deletions intbot/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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.
Expand All @@ -45,7 +50,7 @@ def products(request):
)


@login_required
@staff_required
def submissions(request):
"""
Show some basic aggregation of submissions data
Expand Down
34 changes: 34 additions & 0 deletions intbot/intbot/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"django_extensions",
"django_tasks",
"django_tasks.backends.database",
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.google",
# Project apps
"core",
]
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions intbot/intbot/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -19,4 +20,5 @@
path("days-until/", days_until),
path("products/", products),
path("submissions/", submissions),
path("no-access/", no_access),
]
45 changes: 45 additions & 0 deletions intbot/templates/account/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{% load socialaccount %}
<!DOCTYPE html>
<html>
<head>
<title>Sign In - EuroPython Internal</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.login-box {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
}
h1 { margin-bottom: 0.5rem; }
p { color: #666; margin-bottom: 1.5rem; }
.btn-google {
display: inline-block;
padding: 0.75rem 1.5rem;
background: #4285f4;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 1rem;
}
.btn-google:hover { background: #357ae8; }
</style>
</head>
<body>
<div class="login-box">
<h1>EuroPython Internal</h1>
<p>Sign in with your @europython.eu account</p>
<a class="btn-google" href="{% provider_login_url 'google' %}">Sign in with Google</a>
</div>
</body>
</html>
33 changes: 33 additions & 0 deletions intbot/templates/no_access.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<title>No Access - EuroPython Internal</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.box {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
max-width: 500px;
}
h1 { color: #d32f2f; }
</style>
</head>
<body>
<div class="box">
<h1>Access Denied</h1>
<p>You are logged in but do not have access to this resource yet.</p>
<p>Please contact an admin to get your account promoted.</p>
</div>
</body>
</html>
Loading
Loading