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 @@ + +
+
+ {% lucide "trophy" size=16 class="shrink-0 text-muted" %} + Competitions + + {{ competitions|length }} competition{{ competitions|length|pluralize }} +
+

+ 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. +

+
+ + + + + + + + + + + + {% for competition in competitions %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
NameSportModuleFlag
{{ competition.name }}{{ competition.get_sport_type_display }}{{ competition.module }} + {% if competition.flag %} + {{ competition.flag.name }} + {% else %} + No flag — hidden everywhere + {% endif %} + +
+ + +
+
No competitions yet.
+
+ + {% 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%; }