Add a home-location box to the club detail page in the control panel

Setting a club's home ground here creates/updates the same events.Location
row (flagged is_home) that the club's own Teams > Locations page shows and
edits -- no separate sync step, it's the same record either way. Also
fixes the shared form_field templatetag: django-countries' CountryField
widget reports as "lazyselect", which fell through to a broken plain
text input instead of rendering as a dropdown.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
This commit is contained in:
2026-08-04 12:27:48 +02:00
parent e6850232f0
commit 68cad0c951
7 changed files with 167 additions and 3 deletions

View File

@@ -6,6 +6,7 @@ from waffle import get_waffle_flag_model
from billing.models import DuePayment, Subscription, Tier, TierPrice
from club.models import Club
from events.models import Location
from .services.admins import find_member_by_email
@@ -30,6 +31,21 @@ class ClubForm(forms.ModelForm):
self.fields["slug"].required = False
class HomeLocationForm(forms.ModelForm):
"""Create or update the club's home ground -- this *is* an events.Location row
(flagged ``is_home``), the same one that shows up under the club's own
Teams > Locations page, so the two stay in sync by construction rather than
needing anything to keep them that way."""
class Meta:
model = Location
fields = ["name", "address", "city", "zip_code", "country"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["country"].widget.attrs.update({"data-searchable": "true", "data-search-placeholder": _("Type a country to search...")})
class ClubAdminForm(forms.Form):
"""Grant club-admin rights to an email address, creating the person if new."""

View File

@@ -0,0 +1,39 @@
{% load lucide %}
{% comment %}
The club's home ground -- an events.Location row flagged is_home, so setting or
editing it here creates/updates the very same Location the club's own Teams >
Locations page shows, no separate sync step involved. Included with `club`,
`home_location`, `home_location_form` already in context.
{% endcomment %}
<div class="card mb-6 bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "map-pin" size=18 %} Home location</h2>
<button class="btn btn-primary btn-sm gap-2" type="button" onclick="document.getElementById('club_home_location_modal').showModal()">
{% if home_location %}
{% lucide "pencil" size=16 %} Edit
{% else %}
{% lucide "plus" size=16 %} Set home location
{% endif %}
</button>
</div>
{% if home_location %}
<dl class="divide-y divide-base-200">
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">Name</dt>
<dd class="font-semibold">{{ home_location.name }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">Address</dt>
<dd>{{ home_location.address }}, {{ home_location.zip_code }} {{ home_location.city }}, {{ home_location.country }}</dd>
</div>
</dl>
{% else %}
<p class="text-sm opacity-60">Not set yet. Once set, events at this location can be recognised as home games.</p>
{% endif %}
</div>
</div>
{% url 'controlpanel:club_home_location_set' club.pk as home_location_url %}
{% include "controlpanel/_modal_form.html" with modal_id="club_home_location_modal" title="Home location" form=home_location_form action_url=home_location_url submit_label="Save" submit_icon="save" blurb="This creates or updates a Location for the club -- the same one that shows up on its own Teams > Locations page." %}

View File

@@ -194,12 +194,14 @@
</div>
{% include "controlpanel/_club_features_card.html" %}
{% include "controlpanel/_club_billing_card.html" %}
{% include "controlpanel/_club_home_location_card.html" %}
{% include "controlpanel/_club_admins_card.html" %}
{% endblock panel %}
{% block extra_body %}
{{ charts|json_script:"chart-data" }}
<script src="{% static 'js/chart.js' %}"></script>
<script src="{% static 'js/searchable-select.js' %}"></script>
<script>
(() => {
const data = JSON.parse(document.getElementById("chart-data").textContent);

View File

@@ -152,7 +152,11 @@ def form_field(
field_type = None
match field.widget_type:
case "select" | "nullbooleanselect" | "radioselect":
# "lazyselect" is django_countries' CountryField widget (a Select subclass
# with a lazily-translated choice list) -- without it here, this fell
# through to the "input" case below and rendered as a plain text box
# (`<input type="lazyselect">`, which browsers just treat as text).
case "select" | "nullbooleanselect" | "radioselect" | "lazyselect":
field_type = "select"
case "checkbox":
field_type = "checkbox"

View File

@@ -20,7 +20,7 @@ from billing.models import GRACE_DAYS, Due, Tier, TierPrice
from billing.services import BillingError
from billing.services.dues import record_payment, subscribe, waive
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Attendance, Event
from events.models import Attendance, Event, Location
from features.models import Maintenance
from members.models import Member
from shop.models import Order
@@ -226,6 +226,74 @@ class ClubAdminManagementTests(ControlPanelTestBase):
self.assertContains(response, "ada@example.com")
class ClubHomeLocationTests(ControlPanelTestBase):
"""The club detail page's "Home location" box -- see
controlpanel.views.ClubHomeLocationSetView. Setting it here creates/updates
an events.Location row flagged is_home, which is also what shows up on the
club's own Teams > Locations page (management app) -- there's no separate
sync step, it's the same row."""
def set_home_location(self, **data):
data = {"name": "Home Ground", "address": "1 Main St", "city": "Town", "zip_code": "1000", "country": "BE"} | data
return self.client.post(reverse("controlpanel:club_home_location_set", args=[self.club.pk]), data)
def test_setting_it_creates_a_location(self):
response = self.set_home_location()
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk]))
location = Location.objects.get(club=self.club, is_home=True)
self.assertEqual(location.name, "Home Ground")
def test_the_home_location_modal_renders_country_as_a_dropdown(self):
# Regression: CountryField's widget_type ("lazyselect") wasn't recognised
# by the form_field templatetag and fell through to a broken plain
# <input type="lazyselect">, not a <select> -- see form_field in
# controlpanel/templatetags/ui.py.
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertNotContains(response, 'type="lazyselect"')
self.assertContains(response, "Belgium")
self.assertContains(response, '<select')
def test_the_club_detail_page_shows_the_home_location(self):
self.set_home_location(name="Home Ground")
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "Home Ground")
def test_setting_it_again_updates_the_same_location_instead_of_creating_another(self):
self.set_home_location(name="Home Ground")
self.set_home_location(name="Renamed Ground")
self.assertEqual(Location.objects.filter(club=self.club, is_home=True).count(), 1)
self.assertEqual(Location.objects.get(club=self.club, is_home=True).name, "Renamed Ground")
def test_the_home_location_is_visible_on_the_clubs_own_locations_page(self):
# Same row, no sync needed -- the club-facing management app reads straight
# from events.Location, same as this view writes to.
self.set_home_location(name="Home Ground")
self.client.logout()
admin_user = User.objects.create_user(email="clubadmin@example.com", password="pw-secret-123")
admin_member = Member.objects.create(user=admin_user, first_name="Ada", last_name="Admin")
ClubMembership.objects.create(club=self.club, member=admin_member, season=Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)), status=ClubMembership.StatusChoices.ACTIVE)
ClubRole.objects.filter(club=self.club, member=admin_member).update(role=ClubRole.Roles.ADMIN)
enrol_mfa(admin_user)
self.client.force_login(admin_user)
with override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=[".rosterchief.app"]):
response = self.client.get(reverse("management:location_list"), headers={"host": f"{self.club.slug}.rosterchief.app"})
self.assertContains(response, "Home Ground")
def test_invalid_submission_redirects_back_with_an_error(self):
response = self.set_home_location(name="")
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertFalse(Location.objects.filter(club=self.club, is_home=True).exists())
class StatisticsTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United")

View File

@@ -13,6 +13,7 @@ urlpatterns = [
path("clubs/<uuid:pk>/edit/", views.ClubUpdateView.as_view(), name="club_update"),
path("clubs/<uuid:pk>/archive/", views.ClubArchiveView.as_view(), name="club_archive"),
path("clubs/<uuid:pk>/restore/", views.ClubRestoreView.as_view(), name="club_restore"),
path("clubs/<uuid:pk>/home-location/", views.ClubHomeLocationSetView.as_view(), name="club_home_location_set"),
path("clubs/<uuid:pk>/admins/add/", views.ClubAdminAddView.as_view(), name="club_admin_add"),
path("clubs/<uuid:pk>/admins/<uuid:role_pk>/remove/", views.ClubAdminRemoveView.as_view(), name="club_admin_remove"),
path("clubs/<uuid:pk>/features/<int:flag_pk>/toggle/", views.ClubFeatureToggleView.as_view(), name="club_feature_toggle"),

View File

@@ -15,9 +15,10 @@ from billing.services import BillingError
from billing.services.dues import next_period_start, open_period, reactivate, record_payment, subscribe, waive
from billing.services.invoices import invoice_pdf, issue_invoice
from club.models import Club, ClubRole
from events.models import Location
from features.models import Maintenance
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, HomeLocationForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm
from .messages import notify
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
from .services.admins import grant_club_admin, revoke_club_admin
@@ -134,7 +135,11 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
if due.is_owing:
due.payment_form = DuePaymentForm(initial={"amount": due.balance})
home_location = Location.objects.filter(club=self.object, is_home=True).first()
return super().get_context_data(
home_location=home_location,
home_location_form=HomeLocationForm(instance=home_location),
nav="clubs",
groups=club_statistics(self.object),
attention=club_attention(self.object),
@@ -191,6 +196,35 @@ class ClubAdminAddView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormV
return redirect("controlpanel:club_detail", pk=self.kwargs["pk"])
class ClubHomeLocationSetView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Create or update the club's home Location -- reachable only via the "Home
location" modal on the club detail page. Binds to the existing home Location
(if any) so submitting the form edits it in place rather than ever creating
a second one; ``unique_home_location_per_club`` backs that up at the DB level."""
form_class = HomeLocationForm
http_method_names = ["post"]
invalid_redirect_url_name = "controlpanel:club_detail"
@property
def club(self):
return get_object_or_404(Club, pk=self.kwargs["pk"])
def get_invalid_redirect_kwargs(self):
return {"pk": self.kwargs["pk"]}
def get_form_kwargs(self):
return super().get_form_kwargs() | {"instance": Location.objects.filter(club=self.club, is_home=True).first()}
def form_valid(self, form):
location = form.save(commit=False)
location.club = self.club
location.is_home = True
location.save()
notify(self.request, f"s|Home location set|{location} is now {self.club}'s home location.")
return redirect("controlpanel:club_detail", pk=self.kwargs["pk"])
class ClubAdminRemoveView(PlatformStaffRequiredMixin, View):
def post(self, request, pk, role_pk):
role = get_object_or_404(ClubRole, pk=role_pk, club_id=pk, role=ClubRole.Roles.ADMIN)