Add billing-ending banner, events CRUD, RBIHF import, public API, team photos, and sponsors
A large batch of club-management features built up over one session: - Club dashboard banner warning admins 1 month before billing ends - Full Events/EventSeries CRUD (recurrence builder, occurrence lifecycle, per-team permissions), with match->game rename and game-specific fields (score, competition, live status, external game ID) - Django-admin competition dropdown, gated per-club by feature flag - Auto-import of RBIHF fixtures (scrape -> diff -> preview -> confirm), with location/opponent dropdowns suggested from existing club data - Feature-flag-gated Shop/Forms nav sections, reusing the same flag machinery for the RBIHF import button - Team roster now scoped to members active this season or next, sorted and grouped by position - Club sport type (ice hockey / other), shown in the control panel's club subtitle - Per-season team photo upload from the team page - New public read-only API (Django Ninja) at /api/v1/: news, team rosters, upcoming/live/per-team games, and sponsors -- auto-documented via Swagger UI, CORS-enabled for a club's own external website - Club sponsors: admin-only CRUD (logo, URL, active date window) plus a date-windowed, optionally randomized API endpoint - Assorted fixes: NullBooleanField dropdown rendering, cross-club event validation timing, searchable-select chip placement, btn-neutral -> default button style sweep, calendar-month chart windows Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
This commit is contained in:
@@ -14,7 +14,7 @@ from .services.admins import find_member_by_email
|
||||
class ClubForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["name", "slug", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"]
|
||||
fields = ["name", "slug", "sport_type", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"]
|
||||
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
|
||||
# Deliberately a text input, not <input type="color">: a colour picker cannot
|
||||
# express "no colour" -- it would submit #000000 for every club that never
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="card-title text-base">{% lucide "receipt-euro" size=18 %} Billing</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
|
||||
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('subscription_modal').showModal()">
|
||||
{% lucide "layers" size=14 %} {% if subscription %}Change plan{% else %}Start billing{% endif %}
|
||||
</button>
|
||||
{% if not subscription %}
|
||||
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('trial_modal').showModal()">
|
||||
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('trial_modal').showModal()">
|
||||
{% lucide "hourglass" size=14 %} Start trial
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -87,7 +87,7 @@
|
||||
{% if not due.payments.all %}
|
||||
<form class="inline" method="post" action="{% url 'controlpanel:due_waive' due.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-outline btn-neutral btn-sm gap-1" type="submit">{% lucide "ban" size=14 %} Waive payment</button>
|
||||
<button class="btn btn-outline btn-sm gap-1" type="submit">{% lucide "ban" size=14 %} Waive payment</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<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-outline btn-neutral btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'controlpanel:features' %}">{% lucide "wrench" size=14 %} Manage features</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<a class="link link-hover font-semibold tracking-wide" href="{% url "controlpanel:club_detail" club.pk %}">{{ club.name }}</a>
|
||||
<div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app</div>
|
||||
<div class="text-xs opacity-60">{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -122,7 +122,7 @@
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline btn-neutral gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url "controlpanel:club_detail" club.pk %}">{% lucide "pencil" size=14 %} Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</form>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-outline btn-neutral gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
||||
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
||||
</form>
|
||||
<button class="btn btn-error gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"trash-2" size=16 %} {{ submit_label|default:default_submit_label }}</button>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</form>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-outline btn-neutral gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
||||
<button class="btn btn-outline gap-2">{% lucide "x" size=16 %} {% trans "Cancel" %}</button>
|
||||
</form>
|
||||
<button class="btn btn-primary gap-2" type="submit" form="{{ modal_id }}-form">{% lucide submit_icon|default:"check" size=16 %} {{ submit_label|default:default_submit_label }}</button>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</td>
|
||||
<td class="flex flex-row gap-2 justify-end">
|
||||
<button class="btn btn-primary btn-sm btn-outline gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_price_modal" }}').showModal()">{% lucide "euro" size=14 %} New price</button>
|
||||
<button class="btn btn-sm btn-outline btn-neutral gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ tier.pk|dom_id:"tier_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
@@ -27,14 +27,14 @@
|
||||
{% block heading %}{{ club.name }}{% endblock heading %}
|
||||
|
||||
{% block subheading %}
|
||||
{{ club.slug }}.rosterchief.app
|
||||
{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}
|
||||
{% if club.is_archived %}
|
||||
<span class="badge badge-warning badge-sm ml-2">Archived</span>
|
||||
{% endif %}
|
||||
{% endblock subheading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a>
|
||||
<a class="btn btn-outline gap-2" href="{% url 'controlpanel:club_update' club.pk %}">{% lucide "pencil" size=16 %} Edit</a>
|
||||
<a class="btn btn-primary gap-2" href="https://{{ club.slug }}.rosterchief.app">{% lucide "external-link" size=16 %} Open</a>
|
||||
{% if club.is_archived %}
|
||||
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{% form_field form.name %}
|
||||
{% form_field form.slug %}
|
||||
{% form_field form.sport_type %}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
@@ -36,7 +37,7 @@
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-start pt-2 mt-2">
|
||||
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "controlpanel:club_detail" object.pk %}{% else %}{% url "controlpanel:club_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "controlpanel:club_detail" object.pk %}{% else %}{% url "controlpanel:club_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} Cancel</a>
|
||||
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
{% block actions %}
|
||||
{% if show_archived %}
|
||||
<a class="btn btn-outline btn-neutral" href="{% url 'controlpanel:club_list' %}">{% lucide "archive-x" size=16 %} Hide archived clubs</a>
|
||||
<a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}">{% lucide "archive-x" size=16 %} Hide archived clubs</a>
|
||||
{% else %}
|
||||
<a class="btn btn-outline btn-neutral" href="{% url 'controlpanel:club_list' %}?archived=1">{% lucide "archive" size=16 %} Show archived clubs</a>
|
||||
<a class="btn btn-outline" href="{% url 'controlpanel:club_list' %}?archived=1">{% lucide "archive" size=16 %} Show archived clubs</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-primary gap-2" href="{% url 'controlpanel:club_create' %}">{% lucide "plus" size=16 %} New club</a>
|
||||
{% endblock actions %}
|
||||
@@ -24,7 +24,7 @@
|
||||
class="input input-bordered w-full max-w-xs">
|
||||
</label>
|
||||
|
||||
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "search" size=16 %} Search</button>
|
||||
<button class="btn btn-outline gap-2" type="submit">{% lucide "search" size=16 %} Search</button>
|
||||
{% if search %}
|
||||
<a class="btn btn-primary gap-2" href="{% url "controlpanel:club_list" %}">{% lucide "x" size=16 %} Clear filter</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
<td>{{ flag.clubs.count }}</td>
|
||||
<td class="max-w-xs truncate opacity-70">{{ flag.note|default:"-" }}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-outline btn-neutral btn-sm gap-1" type="button" onclick="document.getElementById('{{ flag.pk|dom_id:"flag_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ flag.pk|dom_id:"flag_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
@@ -54,6 +54,15 @@
|
||||
>{{ field.value|default:"" }}</textarea>
|
||||
|
||||
{% elif field_type == "select" %}
|
||||
{% comment %}
|
||||
field.field.widget.choices, not field.field.choices: a plain ChoiceField
|
||||
exposes both (kept in sync), but NullBooleanField (the "everyone" tri-state
|
||||
Yes/No/Unknown field) only ever has them on the widget -- the field itself
|
||||
has no .choices attribute at all. Reading from the field silently rendered
|
||||
zero <option> tags for it, an empty dropdown, since a template attribute
|
||||
lookup that raises AttributeError just resolves to nothing rather than
|
||||
erroring.
|
||||
{% endcomment %}
|
||||
<select
|
||||
class="select w-full {% if size_modifier %}select-{{ size_modifier }}{% endif %} {% if field.errors %}select-error text-error{% endif %}"
|
||||
name="{{ field.html_name }}"
|
||||
@@ -67,7 +76,7 @@
|
||||
<option selected disabled>{{ field.label|capfirst }}</option>
|
||||
{% endif %}
|
||||
|
||||
{% for option_value, option_label in field.field.choices %}
|
||||
{% for option_value, option_label in field.field.widget.choices %}
|
||||
<option value="{{ option_value }}" {% if option_value in field.value %}selected="selected"{% endif %}>
|
||||
{{ option_label }}
|
||||
</option>
|
||||
@@ -77,7 +86,7 @@
|
||||
<option selected disabled>{{ field.label|capfirst }}</option>
|
||||
{% endif %}
|
||||
|
||||
{% for option_value, option_label in field.field.choices %}
|
||||
{% for option_value, option_label in field.field.widget.choices %}
|
||||
<option value="{{ option_value }}" {% if field.value|stringformat:"s" == option_value|stringformat:"s" %}selected="selected"{% endif %}>
|
||||
{{ option_label }}
|
||||
</option>
|
||||
|
||||
@@ -116,8 +116,16 @@ class ClubManagementTests(ControlPanelTestBase):
|
||||
def test_dashboard_lists_clubs(self):
|
||||
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "Ajax United")
|
||||
|
||||
def test_the_club_rows_subtitle_shows_the_sport_type_behind_the_url(self):
|
||||
self.club.sport_type = Club.SportType.ICE_HOCKEY
|
||||
self.club.save()
|
||||
|
||||
response = self.client.get(reverse("controlpanel:dashboard"))
|
||||
|
||||
self.assertContains(response, f"{self.club.slug}.rosterchief.app · Ice hockey", html=False)
|
||||
|
||||
def test_create_club_derives_the_slug(self):
|
||||
response = self.client.post(reverse("controlpanel:club_create"), {"name": "New Club", "slug": "", "season_start": "2000-08-01", "season_duration_months": "12"})
|
||||
response = self.client.post(reverse("controlpanel:club_create"), {"name": "New Club", "slug": "", "sport_type": "other", "season_start": "2000-08-01", "season_duration_months": "12"})
|
||||
|
||||
club = Club.objects.get(name="New Club")
|
||||
self.assertEqual(club.slug, "new-club")
|
||||
@@ -126,12 +134,21 @@ class ClubManagementTests(ControlPanelTestBase):
|
||||
def test_update_club(self):
|
||||
self.client.post(
|
||||
reverse("controlpanel:club_update", args=[self.club.pk]),
|
||||
{"name": "Renamed", "slug": self.club.slug, "season_start": "2000-08-01", "season_duration_months": "12"},
|
||||
{"name": "Renamed", "slug": self.club.slug, "sport_type": "other", "season_start": "2000-08-01", "season_duration_months": "12"},
|
||||
)
|
||||
|
||||
self.club.refresh_from_db()
|
||||
self.assertEqual(self.club.name, "Renamed")
|
||||
|
||||
def test_update_club_sport_type(self):
|
||||
self.client.post(
|
||||
reverse("controlpanel:club_update", args=[self.club.pk]),
|
||||
{"name": self.club.name, "slug": self.club.slug, "sport_type": "ice_hockey", "season_start": "2000-08-01", "season_duration_months": "12"},
|
||||
)
|
||||
|
||||
self.club.refresh_from_db()
|
||||
self.assertEqual(self.club.sport_type, "ice_hockey")
|
||||
|
||||
def test_club_detail_shows_statistics(self):
|
||||
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
|
||||
|
||||
@@ -139,6 +156,14 @@ class ClubManagementTests(ControlPanelTestBase):
|
||||
self.assertContains(response, "Teams & staff")
|
||||
self.assertContains(response, "Shop")
|
||||
|
||||
def test_club_detail_subheading_shows_the_sport_type_behind_the_url(self):
|
||||
self.club.sport_type = Club.SportType.ICE_HOCKEY
|
||||
self.club.save()
|
||||
|
||||
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
|
||||
|
||||
self.assertContains(response, f"{self.club.slug}.rosterchief.app · Ice hockey", html=False)
|
||||
|
||||
def test_archive_then_restore(self):
|
||||
self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]))
|
||||
self.club.refresh_from_db()
|
||||
@@ -489,6 +514,17 @@ class FeatureViewTests(ControlPanelTestBase):
|
||||
self.assertContains(response, "shop")
|
||||
self.assertContains(response, "maintenance")
|
||||
|
||||
def test_the_everyone_field_renders_as_a_populated_dropdown(self):
|
||||
# Regression: NullBooleanField has no .choices on the field itself (only
|
||||
# on field.widget.choices) -- the shared form_field templatetag read the
|
||||
# wrong attribute and silently rendered the "everyone" tri-state dropdown
|
||||
# with zero <option> tags. See controlpanel/templates/templatetags/field.html.
|
||||
response = self.client.get(reverse("controlpanel:features"))
|
||||
|
||||
self.assertContains(response, '<option value="unknown">Unknown</option>', html=True)
|
||||
self.assertContains(response, '<option value="true">Yes</option>', html=True)
|
||||
self.assertContains(response, '<option value="false">No</option>', html=True)
|
||||
|
||||
def test_the_flag_forms_are_post_only(self):
|
||||
# Reachable only through a modal on the features page: there is no standalone
|
||||
# template to render on a GET.
|
||||
@@ -824,16 +860,20 @@ class FlagAdoptionTests(TestCase):
|
||||
self.club = Club.objects.create(name="Ajax United")
|
||||
|
||||
def test_clubs_are_counted_per_flag(self):
|
||||
# Not an exact-list assertion: migration 0018 seeds real "CEHL"/"RBIHF"
|
||||
# flags for the built-in competitions, so a fresh test database is never
|
||||
# actually flag-free.
|
||||
flag = Flag.objects.create(name="shop")
|
||||
flag.clubs.add(self.club)
|
||||
|
||||
self.assertEqual(flag_adoption(), [{"name": "shop", "clubs": 1, "everyone": None, "overridden": False}])
|
||||
self.assertIn({"name": "shop", "clubs": 1, "everyone": None, "overridden": False}, flag_adoption())
|
||||
|
||||
def test_an_everyone_flag_reports_itself_as_overridden(self):
|
||||
# `everyone` beats club targeting, so the club count would be a lie.
|
||||
Flag.objects.create(name="shop", everyone=True)
|
||||
|
||||
self.assertTrue(flag_adoption()[0]["overridden"])
|
||||
shop_entry = next(entry for entry in flag_adoption() if entry["name"] == "shop")
|
||||
self.assertTrue(shop_entry["overridden"])
|
||||
|
||||
|
||||
class PlatformChartTests(TestCase):
|
||||
|
||||
Reference in New Issue
Block a user