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:
@@ -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."),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
76
controlpanel/services/platform_admins.py
Normal file
76
controlpanel/services/platform_admins.py
Normal 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
|
||||
39
controlpanel/templates/controlpanel/admin_form.html
Normal file
39
controlpanel/templates/controlpanel/admin_form.html
Normal 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 %}
|
||||
73
controlpanel/templates/controlpanel/admins.html
Normal file
73
controlpanel/templates/controlpanel/admins.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
87
controlpanel/templates/controlpanel/features.html
Normal file
87
controlpanel/templates/controlpanel/features.html
Normal 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 %}
|
||||
33
controlpanel/templates/controlpanel/flag_form.html
Normal file
33
controlpanel/templates/controlpanel/flag_form.html
Normal 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 %}
|
||||
@@ -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]))
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
3170
static/css/app.css
3170
static/css/app.css
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user