Manage platform admins and feature flags from the control panel

Features tab: create/edit flags, flip global switches, and toggle a flag per
club from the club detail page. Where `everyone` is set the per-club toggle is
replaced by a badge, because a toggle there would have no effect and so would
lie about what is on.

Admins tab: grant, promote, demote and revoke platform access. Gated on
is_superuser, not is_staff -- the panel itself is staff-accessible, so letting
staff grant is_superuser would collapse the two levels into one and stop
is_superuser being a boundary we can later hang anything on.

Two guardrails, enforced in the service so they hold regardless of caller:
you cannot strip your own access (you would lose the panel mid-click), and the
last superuser can never be demoted (the platform would be locked out of
itself). Granted users get an unusable password and must enrol 2FA before they
can sign in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 16:42:23 +02:00
parent fed24bfee3
commit 268cbe1e06
14 changed files with 3913 additions and 29 deletions

View File

@@ -1,5 +1,6 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from waffle import get_waffle_flag_model
from club.models import Club
@@ -35,3 +36,19 @@ class ClubAdminForm(forms.Form):
self.add_error(field, _("Required: this email has no account yet."))
return cleaned
class PlatformAdminForm(forms.Form):
"""Grant platform access to an email address, creating the account if new."""
email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
is_superuser = forms.BooleanField(label=_("Superuser"), required=False, help_text=_("Superusers can manage platform admins. Everyone granted access is staff."))
class FlagForm(forms.ModelForm):
class Meta:
model = get_waffle_flag_model()
fields = ["name", "note", "everyone", "superusers", "staff", "percent"]
help_texts = {
"everyone": _("Yes = on for all clubs, No = off everywhere (overrides club targeting). Leave unknown to target clubs."),
}

View File

@@ -25,3 +25,16 @@ class PlatformStaffRequiredMixin(UserPassesTestMixin):
def test_func(self):
user = self.request.user
return user.is_staff or user.is_superuser
class PlatformSuperuserRequiredMixin(PlatformStaffRequiredMixin):
"""Superusers only.
Managing platform admins is the one thing staff may not do. The panel is
gated on ``is_staff or is_superuser``, so if a staff member could grant
themselves ``is_superuser`` the two would collapse into the same thing and
``is_superuser`` would stop being a security boundary.
"""
def test_func(self):
return self.request.user.is_superuser

View File

@@ -0,0 +1,76 @@
"""Granting and revoking platform access (is_staff / is_superuser).
The guardrails matter more than the plumbing here: it must be impossible to lock
the platform out of itself. Two rules are enforced for every change:
* you cannot strip your **own** access (you would lose the panel mid-click);
* the **last superuser** can never be demoted, or nobody could administer the
platform again without shell access.
"""
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
User = get_user_model()
class PlatformAdminError(Exception):
"""A change that would leave the platform unadministrable."""
def platform_admins():
return User.objects.filter(Q(is_staff=True) | Q(is_superuser=True)).order_by("email")
def is_last_superuser(user) -> bool:
return user.is_superuser and not User.objects.filter(is_superuser=True).exclude(pk=user.pk).exists()
def check_access_change(actor, user, *, is_staff: bool, is_superuser: bool) -> None:
"""Raise PlatformAdminError if this change would lock someone out."""
losing_access = not (is_staff or is_superuser)
if actor.pk == user.pk and losing_access:
raise PlatformAdminError("You cannot remove your own platform access.")
if actor.pk == user.pk and user.is_superuser and not is_superuser:
raise PlatformAdminError("You cannot remove your own superuser rights.")
if user.is_superuser and not is_superuser and is_last_superuser(user):
raise PlatformAdminError("At least one superuser must remain.")
@transaction.atomic
def set_platform_access(actor, user, *, is_staff: bool, is_superuser: bool):
check_access_change(actor, user, is_staff=is_staff, is_superuser=is_superuser)
# A superuser without is_staff cannot reach the panel, which is a confusing
# half-state; superuser implies staff.
user.is_staff = is_staff or is_superuser
user.is_superuser = is_superuser
user.save(update_fields=["is_staff", "is_superuser"])
return user
def revoke_platform_access(actor, user):
return set_platform_access(actor, user, is_staff=False, is_superuser=False)
@transaction.atomic
def grant_platform_access(email, *, is_superuser: bool = False):
"""Give ``email`` platform access, creating the account if it is new.
New accounts get an unusable password — they set one through the password
reset flow — and, being staff, must enrol a second factor before they can
sign in at all.
"""
email = email.lower()
user, created = User.objects.get_or_create(email=email, defaults={"is_active": True})
if created:
user.set_unusable_password()
user.is_staff = True
user.is_superuser = is_superuser
user.save()
return user

View File

@@ -0,0 +1,39 @@
{% extends "controlpanel/base.html" %}
{% load lucide ui %}
{% block heading %}Grant platform access{% endblock heading %}
{% block panel %}
<div class="card max-w-xl bg-base-100 shadow">
<div class="card-body">
<div class="alert alert-info">
<span>Platform admins can manage every club. They must set up two-factor authentication before they can sign in.</span>
</div>
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2"><span>{{ error }}</span></div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
{% if field.field.widget.input_type == "checkbox" %}
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
{{ field|daisy }}
<span class="label-text">{{ field.label }}</span>
</label>
{% else %}
<label class="label" for="{{ field.id_for_label }}"><span class="label-text">{{ field.label }}</span></label>
{{ field|daisy }}
{% endif %}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-ghost" href="{% url 'controlpanel:admins' %}">Cancel</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "user-plus" size=16 %} Grant access</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,73 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% block heading %}Platform admins{% endblock heading %}
{% block subheading %}
<p class="text-sm opacity-70">Staff run the panel. Superusers additionally manage this list.</p>
{% endblock subheading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:admin_add' %}">{% lucide "user-plus" size=16 %} Grant access</a>
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>User</th>
<th>Staff</th>
<th>Superuser</th>
<th>Last login</th>
<th></th>
</tr>
</thead>
<tbody>
{% for admin in admins %}
<tr>
<td>
<div class="font-medium">{{ admin.email }}</div>
{% if admin.pk == user.pk %}<div class="text-xs opacity-60">That's you</div>{% endif %}
</td>
<td>
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
{% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}0{% else %}1{% endif %}">
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}1{% else %}0{% endif %}">
<button class="btn btn-xs gap-1 {% if admin.is_staff %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
{% if admin.is_staff %}{% lucide "check" size=14 %} Yes{% else %}No{% endif %}
</button>
</form>
</td>
<td>
<form method="post" action="{% url 'controlpanel:admin_update' admin.pk %}">
{% csrf_token %}
<input type="hidden" name="is_staff" value="{% if admin.is_staff %}1{% else %}0{% endif %}">
<input type="hidden" name="is_superuser" value="{% if admin.is_superuser %}0{% else %}1{% endif %}">
<button class="btn btn-xs gap-1 {% if admin.is_superuser %}btn-warning{% else %}btn-ghost{% endif %}" type="submit">
{% if admin.is_superuser %}{% lucide "shield" size=14 %} Yes{% else %}No{% endif %}
</button>
</form>
</td>
<td class="opacity-70">{{ admin.last_login|date:"j M Y"|default:"Never" }}</td>
<td class="text-right">
<form method="post" action="{% url 'controlpanel:admin_revoke' admin.pk %}">
{% csrf_token %}
<button class="btn btn-ghost btn-xs gap-1 text-error" type="submit">{% lucide "user-minus" size=14 %} Revoke</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">No platform admins.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -20,6 +20,10 @@
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit">
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab gap-2 {% if nav == 'dashboard' %}tab-active{% endif %}">{% lucide "layout-dashboard" size=16 %} Dashboard</a>
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab gap-2 {% if nav == 'clubs' %}tab-active{% endif %}">{% lucide "building-2" size=16 %} Clubs</a>
<a role="tab" href="{% url 'controlpanel:features' %}" class="tab gap-2 {% if nav == 'features' %}tab-active{% endif %}">{% lucide "toggle-right" size=16 %} Features</a>
{% if user.is_superuser %}
<a role="tab" href="{% url 'controlpanel:admins' %}" class="tab gap-2 {% if nav == 'admins' %}tab-active{% endif %}">{% lucide "user-cog" size=16 %} Admins</a>
{% endif %}
</div>
{% block panel %}{% endblock panel %}
{% endblock main %}

View File

@@ -50,6 +50,45 @@
</div>
{% endfor %}
</div>
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Features</h2>
<a class="btn btn-ghost btn-xs" href="{% url 'controlpanel:features' %}">Manage features</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for entry in flags %}
<tr>
<td class="font-mono font-medium">{{ entry.flag.name }}</td>
<td class="opacity-70">{{ entry.flag.note|default:"—" }}</td>
<td class="text-right">
{% if entry.overridden %}
{# `everyone` overrides club targeting, so a per-club toggle would be a lie. #}
<span class="badge {% if entry.flag.everyone %}badge-success{% else %}badge-error{% endif %}">
{% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %}
</span>
{% else %}
<form method="post" action="{% url 'controlpanel:club_feature_toggle' club.pk entry.flag.pk %}">
{% csrf_token %}
<button class="btn btn-sm gap-1 {% if entry.enabled %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
{% if entry.enabled %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No features defined yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">

View File

@@ -0,0 +1,87 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% block heading %}Features{% endblock heading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:flag_create' %}">{% lucide "plus" size=16 %} New feature</a>
{% endblock actions %}
{% block panel %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "flag" size=18 %} Flags</h2>
<p class="text-sm opacity-70">
Flags are turned on per club. Setting <em>Everyone</em> to Yes or No overrides club targeting entirely.
</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Everyone</th>
<th>Clubs</th>
<th>Note</th>
<th></th>
</tr>
</thead>
<tbody>
{% for flag in flags %}
<tr>
<td class="font-mono font-medium">{{ flag.name }}</td>
<td>
{% if flag.everyone is True %}
<span class="badge badge-success">On for all</span>
{% elif flag.everyone is False %}
<span class="badge badge-error">Off everywhere</span>
{% else %}
<span class="badge badge-ghost">Per club</span>
{% endif %}
</td>
<td>{{ flag.clubs.count }}</td>
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"—" }}</td>
<td class="text-right">
<a class="btn btn-ghost btn-xs gap-1" href="{% url 'controlpanel:flag_update' flag.pk %}">{% lucide "pencil" size=14 %} Edit</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">No features yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "power" size=18 %} Switches</h2>
<p class="text-sm opacity-70">Global on/off for the whole platform — kill-switches, maintenance, infra rollouts.</p>
<div class="overflow-x-auto">
<table class="table">
<tbody>
{% for switch in switches %}
<tr>
<td class="font-mono font-medium">{{ switch.name }}</td>
<td class="opacity-70">{{ switch.note|default:"—" }}</td>
<td class="text-right">
<form method="post" action="{% url 'controlpanel:switch_toggle' switch.pk %}">
{% csrf_token %}
<button class="btn btn-sm gap-1 {% if switch.active %}btn-success{% else %}btn-ghost{% endif %}" type="submit">
{% if switch.active %}{% lucide "toggle-right" size=16 %} On{% else %}{% lucide "toggle-left" size=16 %} Off{% endif %}
</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No switches yet — add one in the Django admin.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,33 @@
{% extends "controlpanel/base.html" %}
{% load ui %}
{% block heading %}{% if object %}Edit {{ object.name }}{% else %}New feature{% endif %}{% endblock heading %}
{% block panel %}
<div class="card max-w-xl bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-ghost" href="{% url 'controlpanel:features' %}">Cancel</a>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -3,9 +3,11 @@ from decimal import Decimal
from allauth.mfa.models import Authenticator
from django import forms
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from waffle import get_waffle_flag_model, get_waffle_switch_model
from club.models import Club, ClubMembership, ClubRole, Season
from members.models import Member
@@ -13,10 +15,13 @@ from shop.models import Order
from teams.models import Position, Team, TeamMembership
from .services.admins import grant_club_admin
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
from .services.statistics import club_statistics, clubs_with_totals, platform_totals
from .templatetags.ui import daisy
User = get_user_model()
Flag = get_waffle_flag_model()
Switch = get_waffle_switch_model()
def enrol_mfa(user):
@@ -253,3 +258,168 @@ class DaisyFilterTests(TestCase):
def test_invalid_fields_get_an_error_class(self):
self.assertIn("input-error", self.rendered("text", data={}))
class PlatformAdminAccessTests(ControlPanelTestBase):
"""Managing platform admins is superuser-only: the panel is gated on
is_staff OR is_superuser, so letting staff grant superuser would collapse
the two into one privilege level."""
def test_staff_cannot_reach_the_admins_section(self):
self.assertEqual(self.client.get(reverse("controlpanel:admins")).status_code, 403)
def test_staff_cannot_grant_platform_access(self):
self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 403)
def test_the_admins_tab_is_hidden_from_staff(self):
self.assertNotContains(self.client.get(reverse("controlpanel:dashboard")), reverse("controlpanel:admins"))
class PlatformAdminTests(TestCase):
def setUp(self):
self.root = User.objects.create_superuser(email="root@example.com", password="pw-secret-123")
enrol_mfa(self.root)
self.client.force_login(self.root)
def test_superuser_sees_the_admins_section(self):
self.assertEqual(self.client.get(reverse("controlpanel:admins")).status_code, 200)
def test_the_grant_form_renders(self):
self.assertEqual(self.client.get(reverse("controlpanel:admin_add")).status_code, 200)
def test_grant_access_to_a_new_email_creates_a_staff_account(self):
self.client.post(reverse("controlpanel:admin_add"), {"email": "New.Admin@Example.com"})
user = User.objects.get(email="new.admin@example.com")
self.assertTrue(user.is_staff)
self.assertFalse(user.is_superuser)
self.assertFalse(user.has_usable_password()) # set via password reset
def test_grant_superuser(self):
self.client.post(reverse("controlpanel:admin_add"), {"email": "boss@example.com", "is_superuser": "1"})
user = User.objects.get(email="boss@example.com")
self.assertTrue(user.is_superuser)
self.assertTrue(user.is_staff) # superuser implies staff, else they can't reach the panel
def test_promote_and_demote_another_admin(self):
other = User.objects.create_user(email="other@example.com", password="pw-secret-123", is_staff=True)
self.client.post(reverse("controlpanel:admin_update", args=[other.pk]), {"is_staff": "1", "is_superuser": "1"})
other.refresh_from_db()
self.assertTrue(other.is_superuser)
self.client.post(reverse("controlpanel:admin_update", args=[other.pk]), {"is_staff": "1", "is_superuser": "0"})
other.refresh_from_db()
self.assertFalse(other.is_superuser)
def test_revoke_another_admins_access(self):
other = User.objects.create_user(email="other@example.com", password="pw-secret-123", is_staff=True)
self.client.post(reverse("controlpanel:admin_revoke", args=[other.pk]))
other.refresh_from_db()
self.assertFalse(other.is_staff)
self.assertFalse(other.is_superuser)
# --- guardrails: it must be impossible to lock the platform out of itself ---
def test_cannot_revoke_your_own_access(self):
response = self.client.post(reverse("controlpanel:admin_revoke", args=[self.root.pk]), follow=True)
self.root.refresh_from_db()
self.assertTrue(self.root.is_superuser)
self.assertContains(response, "cannot remove your own platform access")
def test_cannot_remove_your_own_superuser_rights(self):
# Keep another superuser around so this is blocked by the self-rule, not
# by the last-superuser rule.
User.objects.create_superuser(email="spare@example.com", password="pw-secret-123")
response = self.client.post(reverse("controlpanel:admin_update", args=[self.root.pk]), {"is_staff": "1", "is_superuser": "0"}, follow=True)
self.root.refresh_from_db()
self.assertTrue(self.root.is_superuser)
self.assertContains(response, "cannot remove your own superuser rights")
def test_the_last_superuser_cannot_be_demoted(self):
other = User.objects.create_superuser(email="other@example.com", password="pw-secret-123")
# Now demote self is blocked by the self-rule; demote `other` is fine...
self.client.post(reverse("controlpanel:admin_update", args=[other.pk]), {"is_staff": "1", "is_superuser": "0"})
other.refresh_from_db()
self.assertFalse(other.is_superuser)
# ...leaving root as the last superuser, who now cannot be demoted by anyone.
self.assertTrue(is_last_superuser(self.root))
with self.assertRaises(PlatformAdminError):
set_platform_access(other, self.root, is_staff=True, is_superuser=False)
def test_last_superuser_rule_is_enforced_for_other_actors_too(self):
response = self.client.post(reverse("controlpanel:admin_revoke", args=[self.root.pk]), follow=True)
self.root.refresh_from_db()
self.assertTrue(self.root.is_superuser)
self.assertContains(response, "cannot remove your own platform access")
class FeatureViewTests(ControlPanelTestBase):
def setUp(self):
super().setUp()
cache.clear()
self.addCleanup(cache.clear)
self.flag = Flag.objects.create(name="shop")
def test_features_page_lists_flags_and_switches(self):
Switch.objects.create(name="maintenance", active=False)
response = self.client.get(reverse("controlpanel:features"))
self.assertContains(response, "shop")
self.assertContains(response, "maintenance")
def test_the_flag_forms_render(self):
self.assertEqual(self.client.get(reverse("controlpanel:flag_create")).status_code, 200)
self.assertEqual(self.client.get(reverse("controlpanel:flag_update", args=[self.flag.pk])).status_code, 200)
def test_create_a_flag(self):
self.client.post(reverse("controlpanel:flag_create"), {"name": "news", "note": "News module", "percent": "", "everyone": ""})
self.assertTrue(Flag.objects.filter(name="news").exists())
def test_edit_a_flag(self):
self.client.post(reverse("controlpanel:flag_update", args=[self.flag.pk]), {"name": "shop", "note": "Webshop", "percent": "", "everyone": ""})
self.flag.refresh_from_db()
self.assertEqual(self.flag.note, "Webshop")
def test_toggle_a_feature_on_and_off_for_a_club(self):
url = reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk])
self.client.post(url)
self.assertTrue(self.flag.clubs.filter(pk=self.club.pk).exists())
self.client.post(url)
self.assertFalse(self.flag.clubs.filter(pk=self.club.pk).exists())
def test_toggle_a_switch(self):
switch = Switch.objects.create(name="maintenance", active=False)
self.client.post(reverse("controlpanel:switch_toggle", args=[switch.pk]))
switch.refresh_from_db()
self.assertTrue(switch.active)
def test_club_detail_offers_a_toggle_per_feature(self):
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "shop")
self.assertContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk]))
def test_an_everyone_flag_shows_a_badge_instead_of_a_club_toggle(self):
# `everyone` overrides club targeting, so offering a per-club toggle would lie.
self.flag.everyone = True
self.flag.save()
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "On for all clubs")
self.assertNotContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk]))

View File

@@ -6,6 +6,7 @@ app_name = "controlpanel"
urlpatterns = [
path("", views.DashboardView.as_view(), name="dashboard"),
# Clubs
path("clubs/", views.ClubListView.as_view(), name="club_list"),
path("clubs/new/", views.ClubCreateView.as_view(), name="club_create"),
path("clubs/<uuid:pk>/", views.ClubDetailView.as_view(), name="club_detail"),
@@ -14,4 +15,15 @@ urlpatterns = [
path("clubs/<uuid:pk>/restore/", views.ClubRestoreView.as_view(), name="club_restore"),
path("clubs/<uuid:pk>/admins/add/", views.ClubAdminAddView.as_view(), name="club_admin_add"),
path("clubs/<uuid:pk>/admins/<uuid:role_pk>/remove/", views.ClubAdminRemoveView.as_view(), name="club_admin_remove"),
path("clubs/<uuid:pk>/features/<int:flag_pk>/toggle/", views.ClubFeatureToggleView.as_view(), name="club_feature_toggle"),
# Features
path("features/", views.FeatureListView.as_view(), name="features"),
path("features/flags/new/", views.FlagCreateView.as_view(), name="flag_create"),
path("features/flags/<int:pk>/edit/", views.FlagUpdateView.as_view(), name="flag_update"),
path("features/switches/<int:pk>/toggle/", views.SwitchToggleView.as_view(), name="switch_toggle"),
# Platform admins (superusers only)
path("admins/", views.PlatformAdminListView.as_view(), name="admins"),
path("admins/add/", views.PlatformAdminAddView.as_view(), name="admin_add"),
path("admins/<uuid:pk>/update/", views.PlatformAdminUpdateView.as_view(), name="admin_update"),
path("admins/<uuid:pk>/revoke/", views.PlatformAdminRevokeView.as_view(), name="admin_revoke"),
]

View File

@@ -1,15 +1,27 @@
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
from waffle import get_waffle_flag_model, get_waffle_switch_model
from club.models import Club, ClubRole
from .forms import ClubAdminForm, ClubForm
from .mixins import PlatformStaffRequiredMixin
from .forms import ClubAdminForm, ClubForm, FlagForm, PlatformAdminForm
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin
from .services.admins import grant_club_admin, revoke_club_admin
from .services.platform_admins import (
PlatformAdminError,
grant_platform_access,
platform_admins,
revoke_platform_access,
set_platform_access,
)
from .services.statistics import club_statistics, clubs_with_totals, platform_totals
Flag = get_waffle_flag_model()
Switch = get_waffle_switch_model()
class DashboardView(PlatformStaffRequiredMixin, TemplateView):
template_name = "controlpanel/dashboard.html"
@@ -80,6 +92,7 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
nav="clubs",
groups=club_statistics(self.object),
admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"),
flags=flags_for_club(self.object),
**kwargs,
)
@@ -126,3 +139,141 @@ class ClubAdminRemoveView(PlatformStaffRequiredMixin, View):
revoke_club_admin(role)
messages.warning(request, f"{member} is no longer an admin of this club.")
return redirect("controlpanel:club_detail", pk=pk)
def flags_for_club(club):
"""Every flag, annotated with whether it is on for this club and why."""
enabled_ids = set(club.flags.values_list("pk", flat=True))
return [
{
"flag": flag,
"enabled": flag.pk in enabled_ids,
# `everyone` overrides club targeting, so the per-club toggle is moot.
"overridden": flag.everyone is not None,
}
for flag in Flag.objects.order_by("name")
]
class ClubFeatureToggleView(PlatformStaffRequiredMixin, View):
"""Turn a feature on or off for one club."""
def post(self, request, pk, flag_pk):
club = get_object_or_404(Club, pk=pk)
flag = get_object_or_404(Flag, pk=flag_pk)
if flag.clubs.filter(pk=club.pk).exists():
flag.clubs.remove(club)
messages.warning(request, f"{flag.name}” turned off for {club}.")
else:
flag.clubs.add(club)
messages.success(request, f"{flag.name}” turned on for {club}.")
return redirect("controlpanel:club_detail", pk=club.pk)
class FeatureListView(PlatformStaffRequiredMixin, TemplateView):
template_name = "controlpanel/features.html"
def get_context_data(self, **kwargs):
return super().get_context_data(
nav="features",
flags=Flag.objects.prefetch_related("clubs").order_by("name"),
switches=Switch.objects.order_by("name"),
**kwargs,
)
class FlagCreateView(PlatformStaffRequiredMixin, CreateView):
model = Flag
form_class = FlagForm
template_name = "controlpanel/flag_form.html"
success_url = None
def get_context_data(self, **kwargs):
return super().get_context_data(nav="features", **kwargs)
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, f"Feature “{self.object.name}” created.")
return response
def get_success_url(self):
return reverse("controlpanel:features")
class FlagUpdateView(PlatformStaffRequiredMixin, UpdateView):
model = Flag
form_class = FlagForm
template_name = "controlpanel/flag_form.html"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="features", **kwargs)
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, f"Feature “{self.object.name}” updated.")
return response
def get_success_url(self):
return reverse("controlpanel:features")
class SwitchToggleView(PlatformStaffRequiredMixin, View):
"""Global kill-switch: on or off for the whole platform."""
def post(self, request, pk):
switch = get_object_or_404(Switch, pk=pk)
switch.active = not switch.active
switch.save()
messages.success(request, f"Switch “{switch.name}” is now {'on' if switch.active else 'off'}.")
return redirect("controlpanel:features")
class PlatformAdminListView(PlatformSuperuserRequiredMixin, TemplateView):
template_name = "controlpanel/admins.html"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="admins", admins=platform_admins(), **kwargs)
class PlatformAdminAddView(PlatformSuperuserRequiredMixin, FormView):
form_class = PlatformAdminForm
template_name = "controlpanel/admin_form.html"
def get_context_data(self, **kwargs):
return super().get_context_data(nav="admins", **kwargs)
def form_valid(self, form):
user = grant_platform_access(form.cleaned_data["email"], is_superuser=form.cleaned_data["is_superuser"])
messages.success(self.request, f"{user.email} now has platform access. They must set up two-factor authentication before they can sign in.")
return redirect("controlpanel:admins")
class PlatformAdminUpdateView(PlatformSuperuserRequiredMixin, View):
def post(self, request, pk):
user = get_object_or_404(get_user_model(), pk=pk)
try:
set_platform_access(
request.user,
user,
is_staff=request.POST.get("is_staff") == "1",
is_superuser=request.POST.get("is_superuser") == "1",
)
except PlatformAdminError as error:
messages.error(request, str(error))
else:
messages.success(request, f"Updated platform access for {user.email}.")
return redirect("controlpanel:admins")
class PlatformAdminRevokeView(PlatformSuperuserRequiredMixin, View):
def post(self, request, pk):
user = get_object_or_404(get_user_model(), pk=pk)
try:
revoke_platform_access(request.user, user)
except PlatformAdminError as error:
messages.error(request, str(error))
else:
messages.warning(request, f"{user.email} no longer has platform access.")
return redirect("controlpanel:admins")

File diff suppressed because one or more lines are too long

View File

@@ -7,14 +7,16 @@
<title>
{% block title %}RosterChief{% endblock title %}
</title>
{# Apply the stored theme before first paint, otherwise the page flashes
the wrong colours. With no stored preference we set nothing, so
daisyUI's `dark --prefersdark` follows the OS. #}
{% comment %}
Apply the stored theme before first paint, otherwise the page flashes the wrong
colours. With no stored preference we set nothing, so daisyUI's `dark --prefersdark`
follows the OS.
{% endcomment %}
<script>
(() => {
const stored = localStorage.getItem("theme");
if (stored) document.documentElement.setAttribute("data-theme", stored);
})();
(() => {
const stored = localStorage.getItem("theme");
if (stored) document.documentElement.setAttribute("data-theme", stored);
})();
</script>
<link rel="stylesheet" href="{% static 'css/app.css' %}">
{% block extra_head %}{% endblock extra_head %}
@@ -88,28 +90,28 @@
{% block main %}{% endblock main %}
</main>
<script>
// No stored preference means "follow the OS", so the effective theme has to
// be read from the OS whenever nothing is set.
const effectiveTheme = () =>
document.documentElement.getAttribute("data-theme") ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
// No stored preference means "follow the OS", so the effective theme has to
// be read from the OS whenever nothing is set.
const effectiveTheme = () =>
document.documentElement.getAttribute("data-theme") ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
const showThemeIcon = () => {
const dark = effectiveTheme() === "dark";
document.querySelectorAll('[data-theme-icon="dark"]').forEach((i) => i.classList.toggle("hidden", !dark));
document.querySelectorAll('[data-theme-icon="light"]').forEach((i) => i.classList.toggle("hidden", dark));
};
const showThemeIcon = () => {
const dark = effectiveTheme() === "dark";
document.querySelectorAll('[data-theme-icon="dark"]').forEach((i) => i.classList.toggle("hidden", !dark));
document.querySelectorAll('[data-theme-icon="light"]').forEach((i) => i.classList.toggle("hidden", dark));
};
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const next = effectiveTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
showThemeIcon();
});
});
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const next = effectiveTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
showThemeIcon();
});
});
showThemeIcon();
showThemeIcon();
</script>
</body>
</html>