diff --git a/controlpanel/forms.py b/controlpanel/forms.py
index d48faad..0a68817 100644
--- a/controlpanel/forms.py
+++ b/controlpanel/forms.py
@@ -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
diff --git a/controlpanel/templates/controlpanel/features.html b/controlpanel/templates/controlpanel/features.html
index 6642a7c..3d091b2 100644
--- a/controlpanel/templates/controlpanel/features.html
+++ b/controlpanel/templates/controlpanel/features.html
@@ -9,15 +9,20 @@
{{ flags|length }} flag{{ flags|length|pluralize }}|{{ switches|length }} switch{{ switches|length|pluralize }}
+ |
+ {{ competitions|length }} competition{{ competitions|length|pluralize }}
{% endblock breadcrumb %}
{% block actions %}
+
{% 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 @@
+
+
+ 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.
+
+
+
+
+
+
Name
+
Sport
+
Module
+
Flag
+
+
+
+
+ {% for competition in competitions %}
+
+
{{ competition.name }}
+
{{ competition.get_sport_type_display }}
+
{{ competition.module }}
+
+ {% if competition.flag %}
+ {{ competition.flag.name }}
+ {% else %}
+ No flag — hidden everywhere
+ {% endif %}
+
+
+
+
+
+
+
+
+ {% empty %}
+
+
No competitions yet.
+
+ {% endfor %}
+
+
+
+
+ {% comment %} Dialogs live outside the table: may only contain
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 %}
+
{% endblock panel %}
diff --git a/controlpanel/tests.py b/controlpanel/tests.py
index 5adf4c0..8b70dbe 100644
--- a/controlpanel/tests.py
+++ b/controlpanel/tests.py
@@ -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("/")
diff --git a/controlpanel/urls.py b/controlpanel/urls.py
index 1415d4c..6b9aefd 100644
--- a/controlpanel/urls.py
+++ b/controlpanel/urls.py
@@ -23,6 +23,9 @@ urlpatterns = [
path("features/flags/new/", views.FlagCreateView.as_view(), name="flag_create"),
path("features/flags//edit/", views.FlagUpdateView.as_view(), name="flag_update"),
path("features/switches//toggle/", views.SwitchToggleView.as_view(), name="switch_toggle"),
+ path("features/competitions/new/", views.CompetitionCreateView.as_view(), name="competition_create"),
+ path("features/competitions//edit/", views.CompetitionUpdateView.as_view(), name="competition_update"),
+ path("features/competitions//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"),
diff --git a/controlpanel/views.py b/controlpanel/views.py
index 7f32131..0ec7999 100644
--- a/controlpanel/views.py
+++ b/controlpanel/views.py
@@ -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."""
diff --git a/static/css/app.css b/static/css/app.css
index aedd3ee..07021d2 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -3497,6 +3497,9 @@
.ml-2 {
margin-left: calc(var(--spacing) * 2);
}
+ .ml-auto {
+ margin-left: auto;
+ }
.status {
@layer daisyui.l1.l2.l3 {
display: inline-block;
@@ -3880,6 +3883,12 @@
.h-48 {
height: calc(var(--spacing) * 48);
}
+ .h-52 {
+ height: calc(var(--spacing) * 52);
+ }
+ .h-56 {
+ height: calc(var(--spacing) * 56);
+ }
.h-72 {
height: calc(var(--spacing) * 72);
}
@@ -3913,9 +3922,15 @@
.h-\[190px\] {
height: 190px;
}
+ .h-fit {
+ height: fit-content;
+ }
.h-full {
height: 100%;
}
+ .h-max {
+ height: max-content;
+ }
.h-px {
height: 1px;
}
@@ -3928,6 +3943,9 @@
.max-h-96 {
max-height: calc(var(--spacing) * 96);
}
+ .max-h-none {
+ max-height: none;
+ }
.min-h-6 {
min-height: calc(var(--spacing) * 6);
}
@@ -4174,6 +4192,9 @@
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
+ .grid-cols-4 {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
.grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
@@ -4317,6 +4338,9 @@
.rounded-md {
border-radius: var(--radius-md);
}
+ .rounded-none {
+ border-radius: 0;
+ }
.rounded-sm {
border-radius: var(--radius-sm);
}
@@ -4339,6 +4363,10 @@
border-top-style: var(--tw-border-style);
border-top-width: 1px;
}
+ .border-r {
+ border-right-style: var(--tw-border-style);
+ border-right-width: 1px;
+ }
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
@@ -4504,6 +4532,31 @@
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
}
}
+ .btn-soft {
+ @layer daisyui.l1.l2.l3 {
+ --btn-bg: var(--btn-color, var(--color-base-content));
+ @supports (color: color-mix(in lab, red, red)) {
+ --btn-bg: color-mix(
+ in oklab,
+ var(--btn-color, var(--color-base-content)) 8%,
+ var(--btn-soft-bg, var(--color-base-100))
+ );
+ }
+ color: var(--btn-rest-fg, var(--btn-color, var(--color-base-content)));
+ --btn-border: var(--btn-color, var(--color-base-content));
+ @supports (color: color-mix(in lab, red, red)) {
+ --btn-border: color-mix(
+ in oklab,
+ var(--btn-color, var(--color-base-content)) 10%,
+ var(--btn-soft-bg, var(--color-base-100))
+ );
+ }
+ --btn-border-style: solid;
+ background-image: none;
+ --btn-inset: 0 0 0 0 oklch(0% 0 0/0);
+ --btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
+ }
+ }
.btn-ghost {
@layer daisyui.l1.l2.l3 {
--btn-bg: #0000;
@@ -4526,6 +4579,9 @@
.object-cover {
object-fit: cover;
}
+ .p-0 {
+ padding: 0;
+ }
.p-1 {
padding: var(--spacing);
}
@@ -5027,6 +5083,9 @@
.opacity-0 {
opacity: 0%;
}
+ .opacity-40 {
+ opacity: 40%;
+ }
.opacity-50 {
opacity: 50%;
}