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:
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user