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