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 billing.models import DuePayment, Subscription, Tier, TierPrice
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
|
from events.models import Location
|
||||||
|
|
||||||
from .services.admins import find_member_by_email
|
from .services.admins import find_member_by_email
|
||||||
|
|
||||||
@@ -30,6 +31,21 @@ class ClubForm(forms.ModelForm):
|
|||||||
self.fields["slug"].required = False
|
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):
|
class ClubAdminForm(forms.Form):
|
||||||
"""Grant club-admin rights to an email address, creating the person if new."""
|
"""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>
|
</div>
|
||||||
{% include "controlpanel/_club_features_card.html" %}
|
{% include "controlpanel/_club_features_card.html" %}
|
||||||
{% include "controlpanel/_club_billing_card.html" %}
|
{% include "controlpanel/_club_billing_card.html" %}
|
||||||
|
{% include "controlpanel/_club_home_location_card.html" %}
|
||||||
{% include "controlpanel/_club_admins_card.html" %}
|
{% include "controlpanel/_club_admins_card.html" %}
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|
||||||
{% block extra_body %}
|
{% block extra_body %}
|
||||||
{{ charts|json_script:"chart-data" }}
|
{{ charts|json_script:"chart-data" }}
|
||||||
<script src="{% static 'js/chart.js' %}"></script>
|
<script src="{% static 'js/chart.js' %}"></script>
|
||||||
|
<script src="{% static 'js/searchable-select.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
const data = JSON.parse(document.getElementById("chart-data").textContent);
|
||||||
|
|||||||
@@ -152,7 +152,11 @@ def form_field(
|
|||||||
|
|
||||||
field_type = None
|
field_type = None
|
||||||
match field.widget_type:
|
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"
|
field_type = "select"
|
||||||
case "checkbox":
|
case "checkbox":
|
||||||
field_type = "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 import BillingError
|
||||||
from billing.services.dues import record_payment, subscribe, waive
|
from billing.services.dues import record_payment, subscribe, waive
|
||||||
from club.models import Club, ClubMembership, ClubRole, Season
|
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 features.models import Maintenance
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
from shop.models import Order
|
from shop.models import Order
|
||||||
@@ -226,6 +226,74 @@ class ClubAdminManagementTests(ControlPanelTestBase):
|
|||||||
self.assertContains(response, "ada@example.com")
|
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):
|
class StatisticsTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.club = Club.objects.create(name="Ajax United")
|
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>/edit/", views.ClubUpdateView.as_view(), name="club_update"),
|
||||||
path("clubs/<uuid:pk>/archive/", views.ClubArchiveView.as_view(), name="club_archive"),
|
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>/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/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>/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"),
|
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.dues import next_period_start, open_period, reactivate, record_payment, subscribe, waive
|
||||||
from billing.services.invoices import invoice_pdf, issue_invoice
|
from billing.services.invoices import invoice_pdf, issue_invoice
|
||||||
from club.models import Club, ClubRole
|
from club.models import Club, ClubRole
|
||||||
|
from events.models import Location
|
||||||
from features.models import Maintenance
|
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 .messages import notify
|
||||||
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
|
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
|
||||||
from .services.admins import grant_club_admin, revoke_club_admin
|
from .services.admins import grant_club_admin, revoke_club_admin
|
||||||
@@ -134,7 +135,11 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
|
|||||||
if due.is_owing:
|
if due.is_owing:
|
||||||
due.payment_form = DuePaymentForm(initial={"amount": due.balance})
|
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(
|
return super().get_context_data(
|
||||||
|
home_location=home_location,
|
||||||
|
home_location_form=HomeLocationForm(instance=home_location),
|
||||||
nav="clubs",
|
nav="clubs",
|
||||||
groups=club_statistics(self.object),
|
groups=club_statistics(self.object),
|
||||||
attention=club_attention(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"])
|
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):
|
class ClubAdminRemoveView(PlatformStaffRequiredMixin, View):
|
||||||
def post(self, request, pk, role_pk):
|
def post(self, request, pk, role_pk):
|
||||||
role = get_object_or_404(ClubRole, pk=role_pk, club_id=pk, role=ClubRole.Roles.ADMIN)
|
role = get_object_or_404(ClubRole, pk=role_pk, club_id=pk, role=ClubRole.Roles.ADMIN)
|
||||||
|
|||||||
Reference in New Issue
Block a user