Add a Competitions card to the control panel's Features page

Competition rows (events.services.competitions' per-club data-source
gate) could previously only be managed through the Django admin.
Adds a card alongside Flags/Switches with create/edit/delete, mirroring
the Flags card's own modal pattern -- no cascading effects to weigh on
delete since Event.competition matches by name, not a foreign key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-20 21:40:13 +02:00
parent 3159064e2a
commit 5889b3740e
6 changed files with 269 additions and 4 deletions

View File

@@ -6,7 +6,7 @@ from waffle import get_waffle_flag_model
from billing.models import DuePayment, Plan, PlanPrice, Subscription
from club.models import Club
from events.models import Location
from events.models import Competition, Location
from .services.admins import find_member_by_email
@@ -82,6 +82,24 @@ class FlagForm(forms.ModelForm):
}
class CompetitionForm(forms.ModelForm):
"""Metadata for events.services.competitions.fetch_game_info's per-club gate --
`module` is a dotted import path to a class named `name` that implements
`update_game_information(event=...)`; there is no such class for a new
competition until one is actually written, but that's fine here, same as
editing this by hand in the Django admin today: fetch_game_info already
catches the resulting ImportError/AttributeError and treats it as "nothing to
fetch from" rather than a 500, so this form doesn't need to validate the path
against real code to be safe to use."""
class Meta:
model = Competition
fields = ["name", "module", "sport_type", "flag"]
help_texts = {
"module": _("Dotted path to the Python module implementing this competition's data source, e.g. events.services.competitions.cehl."),
}
class PlanForm(forms.ModelForm):
"""Field order is chosen for the two-column modal (see _form_fields.html): description
spans both columns, so pairing name with duration and the two day-counts with each other

View File

@@ -9,15 +9,20 @@
<span>{{ flags|length }} flag{{ flags|length|pluralize }}</span>
<span class="text-edge">|</span>
<span>{{ switches|length }} switch{{ switches|length|pluralize }}</span>
<span class="text-edge">|</span>
<span>{{ competitions|length }} competition{{ competitions|length|pluralize }}</span>
{% endblock breadcrumb %}
{% block actions %}
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('flag_create_modal').showModal()">{% lucide "plus" size=16 %} New feature</button>
<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('competition_create_modal').showModal()">{% lucide "plus" size=16 %} New competition</button>
{% endblock actions %}
{% block panel %}
{% url 'controlpanel:flag_create' as flag_create_url %}
{% include "controlpanel/_modal_form.html" with modal_id="flag_create_modal" title="New feature" form=flag_form action_url=flag_create_url submit_label="Create" submit_icon="plus" %}
{% url 'controlpanel:competition_create' as competition_create_url %}
{% include "controlpanel/_modal_form.html" with modal_id="competition_create_modal" title="New competition" form=competition_form action_url=competition_create_url submit_label="Create" submit_icon="plus" %}
{% comment %}
The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the
@@ -171,4 +176,64 @@
</table>
</div>
</div>
<div class="card">
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
{% lucide "trophy" size=16 class="shrink-0 text-muted" %}
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Competitions</span>
<span class="flex-1"></span>
<span class="font-mono text-[11px] text-muted">{{ competitions|length }} competition{{ competitions|length|pluralize }}</span>
</div>
<p class="px-4 pt-3 pb-3 text-sm text-muted">
Data sources a game's score/status can be fetched from — see events.services.competitions. A club only sees one in its event form once its feature flag is on for them.
</p>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Sport</th>
<th>Module</th>
<th>Flag</th>
<th></th>
</tr>
</thead>
<tbody>
{% for competition in competitions %}
<tr>
<td class="font-mono">{{ competition.name }}</td>
<td>{{ competition.get_sport_type_display }}</td>
<td class="max-w-xs truncate text-muted">{{ competition.module }}</td>
<td>
{% if competition.flag %}
<span class="badge badge-info">{{ competition.flag.name }}</span>
{% else %}
<span class="badge badge-neutral">No flag — hidden everywhere</span>
{% endif %}
</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ competition.pk|dom_id:"competition_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
<button class="btn btn-outline btn-error btn-sm gap-1" type="button" onclick="document.getElementById('{{ competition.pk|dom_id:"competition_delete_modal" }}').showModal()">{% lucide "trash-2" size=14 %} Delete</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">No competitions yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
{% for competition in competitions %}
{% url 'controlpanel:competition_update' competition.pk as competition_update_url %}
{% include "controlpanel/_modal_form.html" with modal_id=competition.pk|dom_id:"competition_edit_modal" title="Edit "|add:competition.name form=competition.edit_form action_url=competition_update_url submit_label="Save" submit_icon="check" %}
{% url 'controlpanel:competition_delete' competition.pk as competition_delete_url %}
{% include "controlpanel/_confirm_modal.html" with modal_id=competition.pk|dom_id:"competition_delete_modal" title="Delete "|add:competition.name body="No club-visible data is lost — Event.competition matches by name, not a foreign key, so existing games simply stop offering a live-score fetch." action_url=competition_delete_url submit_label="Delete" %}
{% endfor %}
</div>
{% endblock panel %}

View File

@@ -21,7 +21,7 @@ from billing.models import DEFAULT_GRACE_DAYS, Due, Plan, PlanPrice, Subscriptio
from billing.services import BillingError
from billing.services.dues import record_payment, start_trial, subscribe, waive
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Event, Location
from events.models import Attendance, Competition, Event, Location
from features.models import Maintenance
from members.models import Member
from shop.models import Order
@@ -601,6 +601,68 @@ class FeatureViewTests(ControlPanelTestBase):
self.assertNotContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk]))
class CompetitionCrudTests(ControlPanelTestBase):
"""Manage events.models.Competition rows from the Features page -- see
events.services.competitions for how `module`/`name` get used."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.competition = Competition.objects.create(name="CEHL", module="events.services.competitions.cehl", sport_type=Club.SportType.OTHER)
def test_features_page_lists_competitions(self):
response = self.client.get(reverse("controlpanel:features"))
self.assertContains(response, "CEHL")
self.assertContains(response, "events.services.competitions.cehl")
def test_the_competition_forms_are_post_only(self):
self.assertEqual(self.client.get(reverse("controlpanel:competition_create")).status_code, 405)
self.assertEqual(self.client.get(reverse("controlpanel:competition_update", args=[self.competition.pk])).status_code, 405)
def test_an_invalid_competition_submission_redirects_with_a_message(self):
response = self.client.post(reverse("controlpanel:competition_create"), {"name": "", "module": "", "sport_type": Club.SportType.OTHER}, follow=True)
self.assertRedirects(response, reverse("controlpanel:features"))
self.assertContains(response, "This field is required")
def test_create_a_competition(self):
self.client.post(reverse("controlpanel:competition_create"), {"name": "BFL", "module": "events.services.competitions.bfl", "sport_type": Club.SportType.OTHER})
self.assertTrue(Competition.objects.filter(name="BFL").exists())
def test_edit_a_competition(self):
self.client.post(reverse("controlpanel:competition_update", args=[self.competition.pk]), {"name": "CEHL", "module": "events.services.competitions.cehl_v2", "sport_type": Club.SportType.OTHER})
self.competition.refresh_from_db()
self.assertEqual(self.competition.module, "events.services.competitions.cehl_v2")
def test_delete_a_competition(self):
self.client.post(reverse("controlpanel:competition_delete", args=[self.competition.pk]))
self.assertFalse(Competition.objects.filter(pk=self.competition.pk).exists())
def test_deleting_does_not_touch_an_event_that_matched_it_by_name(self):
# Event.competition is a plain name match, not a foreign key -- deleting the
# Competition row must not cascade or error.
season = Season.objects.create(club=self.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
event = Event.objects.create(club=self.club, season=season, kind=Event.EventKind.GAME, title="Match", start=timezone.now(), competition="CEHL")
self.client.post(reverse("controlpanel:competition_delete", args=[self.competition.pk]))
event.refresh_from_db()
self.assertEqual(event.competition, "CEHL")
def test_a_non_staff_user_gets_redirected(self):
self.client.logout()
plain_user = User.objects.create_user(email="plain-competition@example.com", password="pw-secret-123")
self.client.force_login(plain_user)
response = self.client.get(reverse("controlpanel:features"))
self.assertNotEqual(response.status_code, 200)
class NotifyTests(TestCase):
def request(self):
request = RequestFactory().get("/")

View File

@@ -23,6 +23,9 @@ urlpatterns = [
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"),
path("features/competitions/new/", views.CompetitionCreateView.as_view(), name="competition_create"),
path("features/competitions/<int:pk>/edit/", views.CompetitionUpdateView.as_view(), name="competition_update"),
path("features/competitions/<int:pk>/delete/", views.CompetitionDeleteView.as_view(), name="competition_delete"),
# Billing (platform charging the clubs)
path("billing/", views.BillingView.as_view(), name="billing"),
path("billing/plans/new/", views.PlanCreateView.as_view(), name="plan_create"),

View File

@@ -18,10 +18,10 @@ from billing.services.dues import next_period_start, open_period, reactivate, re
from billing.services.invoices import invoice_pdf, issue_invoice
from billing.services.plans import delete_plan, plan_deletion_impact
from club.models import Club, ClubRole
from events.models import Location
from events.models import Competition, Location
from features.models import Maintenance
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, HomeLocationForm, MaintenanceForm, OpenPeriodForm, PlanForm, PlanPriceForm, PlatformAdminForm, SubscriptionForm, TrialForm
from .forms import ClubAdminForm, ClubForm, CompetitionForm, DuePaymentForm, FlagForm, HomeLocationForm, MaintenanceForm, OpenPeriodForm, PlanForm, PlanPriceForm, PlatformAdminForm, SubscriptionForm, TrialForm
from .messages import notify
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
from .services.admins import grant_club_admin, revoke_club_admin
@@ -285,10 +285,16 @@ class FeatureListView(PlatformStaffRequiredMixin, TemplateView):
for flag in flags:
flag.edit_form = FlagForm(instance=flag)
competitions = list(Competition.objects.select_related("flag").order_by("name"))
for competition in competitions:
competition.edit_form = CompetitionForm(instance=competition)
return super().get_context_data(
nav="features",
flags=flags,
flag_form=FlagForm(),
competitions=competitions,
competition_form=CompetitionForm(),
switches=Switch.objects.order_by("name"),
maintenance=Maintenance.current(),
maintenance_form=MaintenanceForm(),
@@ -348,6 +354,58 @@ class FlagUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateV
return reverse("controlpanel:features")
class CompetitionCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
"""Reachable only via the "New competition" modal on the features page --
POST-only, and there is no standalone template to render on GET or on a
rejected submission."""
model = Competition
form_class = CompetitionForm
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:features"
def form_valid(self, form):
response = super().form_valid(form)
notify(self.request, f"s|Competition created|Competition “{self.object.name}” created.")
return response
def get_success_url(self):
return reverse("controlpanel:features")
class CompetitionUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView):
"""Reachable only via a competition's "Edit" modal on the features page --
POST-only, and there is no standalone template to render on GET or on a
rejected submission."""
model = Competition
form_class = CompetitionForm
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:features"
def form_valid(self, form):
response = super().form_valid(form)
notify(self.request, f"s|Competition updated|Competition “{self.object.name}” updated.")
return response
def get_success_url(self):
return reverse("controlpanel:features")
class CompetitionDeleteView(PlatformStaffRequiredMixin, View):
"""No cascading consequences to weigh (Event.competition matches by name, not
a foreign key -- see events.services.competitions), so this is a plain confirm
modal rather than PlanDeleteView's dedicated impact page."""
def post(self, request, pk):
competition = get_object_or_404(Competition, pk=pk)
name = competition.name
competition.delete()
notify(request, f"w|Competition deleted|Competition “{name}” has been deleted.")
return redirect("controlpanel:features")
class SwitchToggleView(PlatformStaffRequiredMixin, View):
"""Global kill-switch: on or off for the whole platform."""