diff --git a/gig/helpers.py b/gig/helpers.py index 1208aa86..6dfab841 100644 --- a/gig/helpers.py +++ b/gig/helpers.py @@ -58,6 +58,19 @@ def decorated(request, pk, *args, **kw): return func(request, p, *args, **kw) return decorated +def plan_band_admin_required(func): + """ Like plan_editor_required, but attendance can only be taken by a band admin + (or superuser) -- not by the member themselves. """ + def decorated(request, pk, *args, **kw): + # Imported lazily: a module-load import creates a circular dependency + # (member.helpers -> band.helpers -> gig.helpers). + from member.helpers import has_band_admin + p = get_object_or_404(Plan, pk=pk) + if not has_band_admin(request.user, p.assoc.band): + return HttpResponseForbidden() + return func(request, p, *args, **kw) + return decorated + @login_required @plan_editor_required @@ -89,6 +102,24 @@ def update_plan_section(request, plan, val): plan.save() return HttpResponse(status=204) +@login_required +@plan_band_admin_required +def toggle_attendance(request, plan): + """ Toggle whether a member attended the gig. Band-admin only; persists per tap + via htmx and returns the refreshed roster row fragment. """ + plan.attended = not plan.attended + plan.save() + + # Record who most recently changed attendance for this gig, and when. + gig = plan.gig + gig.attendance_taken_by = request.user + gig.attendance_taken_at = now() + gig.save() + + # Return only the checkmark icon: the row element stays put so its (JS-managed) + # visibility/filter state isn't disturbed by the swap. + return render(request, 'gig/attendance_check.html', {'plan': plan}) + # @login_required def update_plan_default_section(assoc): diff --git a/gig/migrations/0035_gig_attendance_taken_at_gig_attendance_taken_by_and_more.py b/gig/migrations/0035_gig_attendance_taken_at_gig_attendance_taken_by_and_more.py new file mode 100644 index 00000000..0d487b91 --- /dev/null +++ b/gig/migrations/0035_gig_attendance_taken_at_gig_attendance_taken_by_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.30 on 2026-06-21 18:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('gig', '0034_alter_gig_safe_date_alter_gig_safe_enddate_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='gig', + name='attendance_taken_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='gig', + name='attendance_taken_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='historicalgig', + name='attendance_taken_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalgig', + name='attendance_taken_by', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='plan', + name='attended', + field=models.BooleanField(default=False), + ), + ] diff --git a/gig/models.py b/gig/models.py index 68034e2e..8e1f316b 100644 --- a/gig/models.py +++ b/gig/models.py @@ -73,6 +73,11 @@ class Plan(models.Model): status = models.IntegerField(choices=PlanStatusChoices.choices, default=PlanStatusChoices.NO_PLAN) status_changed = models.BooleanField(default=False) + # whether the member actually showed up at the gig. Set by a band admin via the + # attendance-taking view. False is treated as "absent / not yet marked"; whether + # attendance was ever taken at all is recorded on the Gig (attendance_taken_at). + attended = models.BooleanField(default=False) + @property def status_string(self): return PlanStatusChoices(self.status).label @@ -207,6 +212,12 @@ class Gig(AbstractEvent): # Flag whether band members can change their plans plans_locked = models.BooleanField(default=False) + # Attendance tracking: who recorded attendance for this gig, and when. These stay + # null until a band admin marks the first member present in the attendance view. + attendance_taken_by = models.ForeignKey('member.Member', blank=True, null=True, + related_name='+', on_delete=models.SET_NULL) + attendance_taken_at = models.DateTimeField(null=True, blank=True) + # for use in calfeeds cal_feed_id = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) diff --git a/gig/tests.py b/gig/tests.py index f81bd17c..7b9380c2 100644 --- a/gig/tests.py +++ b/gig/tests.py @@ -1614,3 +1614,95 @@ def test_api_throttle(self): self.assertEqual(response.status_code, 429) self.assertTrue(count <= THROTTLE_PER_SECOND) + +class AttendanceTest(GigTestBase): + """ Tests for the band-admin attendance-taking view and toggle endpoint. """ + + def _set_status(self, plan, status): + plan.status = status + plan.save() + return plan + + # --- attendance page (view) permissions --- + + def test_attendance_view_admin(self): + g, _, _ = self.assoc_joe_and_create_gig() + self.client.force_login(self.band_admin) + resp = self.client.get(reverse("gig-attendance", args=[g.id])) + self.assertEqual(resp.status_code, 200) + + def test_attendance_view_superuser(self): + g, _, _ = self.assoc_joe_and_create_gig() + self.client.force_login(self.super) + resp = self.client.get(reverse("gig-attendance", args=[g.id])) + self.assertEqual(resp.status_code, 200) + + def test_attendance_view_non_admin_member_forbidden(self): + # joe is a confirmed member but not a band admin + g, _, _ = self.assoc_joe_and_create_gig() + self.client.force_login(self.joeuser) + resp = self.client.get(reverse("gig-attendance", args=[g.id])) + self.assertEqual(resp.status_code, 403) + + def test_attendance_view_anonymous_redirected(self): + g, _, _ = self.assoc_joe_and_create_gig() + resp = self.client.get(reverse("gig-attendance", args=[g.id])) + # LoginRequiredMixin redirects anonymous users to login + self.assertEqual(resp.status_code, 302) + + def test_attendance_default_roster_marks_attending(self): + """ The roster rows carry the 'attending' flag for DEFINITELY/PROBABLY plans + so the client-side default filter can show just those. """ + g, _, p = self.assoc_joe_and_create_gig() + self._set_status(p, PlanStatusChoices.DEFINITELY) + self.client.force_login(self.band_admin) + resp = self.client.get(reverse("gig-attendance", args=[g.id])) + content = resp.content.decode("utf-8") + self.assertIn(f'id="attendance-row-{p.id}"', content) + self.assertIn('data-attending="1"', content) + + # --- toggle endpoint --- + + def test_toggle_attendance_admin(self): + _, _, p = self.assoc_joe_and_create_gig() + self.assertFalse(p.attended) + self.client.force_login(self.band_admin) + resp = self.client.post(reverse("plan-attendance-toggle", args=[p.id])) + self.assertEqual(resp.status_code, 200) + p.refresh_from_db() + self.assertTrue(p.attended) + # toggling again flips it back + resp = self.client.post(reverse("plan-attendance-toggle", args=[p.id])) + self.assertEqual(resp.status_code, 200) + p.refresh_from_db() + self.assertFalse(p.attended) + + def test_toggle_attendance_stamps_gig(self): + g, _, p = self.assoc_joe_and_create_gig() + self.assertIsNone(g.attendance_taken_at) + self.assertIsNone(g.attendance_taken_by) + self.client.force_login(self.band_admin) + self.client.post(reverse("plan-attendance-toggle", args=[p.id])) + g.refresh_from_db() + self.assertIsNotNone(g.attendance_taken_at) + self.assertEqual(g.attendance_taken_by, self.band_admin) + first_stamp = g.attendance_taken_at + # a later toggle by a different admin updates the stamp to the last editor + Assoc.objects.create( + member=self.super, band=self.band, is_admin=True, + status=AssocStatusChoices.CONFIRMED) + with freeze_time(first_stamp + timedelta(minutes=5)): + self.client.force_login(self.super) + self.client.post(reverse("plan-attendance-toggle", args=[p.id])) + g.refresh_from_db() + self.assertEqual(g.attendance_taken_by, self.super) + self.assertGreater(g.attendance_taken_at, first_stamp) + + def test_toggle_attendance_non_admin_forbidden(self): + _, _, p = self.assoc_joe_and_create_gig() + self.client.force_login(self.joeuser) + resp = self.client.post(reverse("plan-attendance-toggle", args=[p.id])) + self.assertEqual(resp.status_code, 403) + p.refresh_from_db() + self.assertFalse(p.attended) + diff --git a/gig/urls.py b/gig/urls.py index 8377b409..bdb3ad75 100644 --- a/gig/urls.py +++ b/gig/urls.py @@ -36,8 +36,10 @@ path('/printallplans', views.PrintPlansView.as_view(), {'all':True}, name='gig-print-all-plans'), path('/printconfirmedplans', views.PrintPlansView.as_view(), {'all':False}, name='gig-print-confirmed-plans'), path('/printsetlist', views.PrintSetlistView.as_view(), name='gig-print-setlist'), + path('/attendance', views.AttendanceView.as_view(), name='gig-attendance'), path('plan//update/', helpers.update_plan, name='plan-update'), + path('plan//attendance', helpers.toggle_attendance, name='plan-attendance-toggle'), path('plan//feedback/', helpers.update_plan_feedback, name='plan-update-feedback'), path('plan//comment', helpers.update_plan_comment, name='plan-update-comment'), path('plan//section/', helpers.update_plan_section, name='plan-update-section'), diff --git a/gig/views.py b/gig/views.py index f36e9465..774c61ce 100644 --- a/gig/views.py +++ b/gig/views.py @@ -86,6 +86,29 @@ def get_context_data(self, **kwargs): return context +class AttendanceView(LoginRequiredMixin, UserPassesTestMixin, generic.DetailView): + """ Mobile-friendly view for band admins to take attendance at a gig. + + The full roster is rendered once; searching, the attending/all toggle, sorting + and the running count are all handled client-side so the page works well on a + phone with a flaky connection. Marking a member present persists per-tap via htmx. + """ + model = Gig + template_name = 'gig/gig_attendance.html' + + def test_func(self): + gig = get_object_or_404(Gig, id=self.kwargs['pk']) + return has_band_admin(self.request.user, gig.band) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Order by section then name so the template can regroup by section, just like + # the gig detail / print plan list pages. The order MUST group by section. + context['gig_ordered_member_plans'] = self.object.member_plans.all().order_by( + 'section', Lower('assoc__member__display_name')) + return context + + class CreateView(LoginRequiredMixin, UserPassesTestMixin, generic.CreateView): model = Gig form_class = GigForm diff --git a/templates/gig/attendance_check.html b/templates/gig/attendance_check.html new file mode 100644 index 00000000..0a72fadc --- /dev/null +++ b/templates/gig/attendance_check.html @@ -0,0 +1 @@ +{% if plan.attended %}{% else %}{% endif %} diff --git a/templates/gig/attendance_row.html b/templates/gig/attendance_row.html new file mode 100644 index 00000000..092cbd5b --- /dev/null +++ b/templates/gig/attendance_row.html @@ -0,0 +1,15 @@ +{% load i18n %} + diff --git a/templates/gig/gig_attendance.html b/templates/gig/gig_attendance.html new file mode 100644 index 00000000..9bf5e14b --- /dev/null +++ b/templates/gig/gig_attendance.html @@ -0,0 +1,232 @@ +{% extends "base/go3base.html" %} +{% load i18n static gig_extras %} + +{% block title %}{% trans "Attendance" %} · {{ gig.title }}{% endblock title %} + +{% block headcontent %} + +{% endblock headcontent %} + +{% block content %} +
+
+ + {% trans "Back to gig" %} + +
{{ gig.title }}
+
{% trans "Attendance" %}{% if gig.date %} · {{ gig.date|date:"DATE_FORMAT" }}{% endif %}
+ + + +
+
+ + +
+
+ 0 {% trans "present" %} / + 0 {% trans "shown" %} +
+
+
+ +
+ {% regroup gig_ordered_member_plans by section as plans_by_section %} + {% with multi_section=gig.band.sections.all|length %} + {% for section in gig.band.sections.all %} + {% with section_plans=plans_by_section|lookup_plans:section %} + {% if section_plans %} +
+ {% if multi_section > 1 %} +
{{ section.display_name }}
+ {% endif %} +
+ {% for plan in section_plans %} + {% include 'gig/attendance_row.html' with plan=plan %} + {% endfor %} +
+
+ {% endif %} + {% endwith %} + {% empty %} +
{% trans "No members to show." %}
+ {% endfor %} + {% endwith %} +
+
+{% endblock content %} + +{% block localscripts %} + +{% endblock localscripts %} diff --git a/templates/gig/gig_detail.html b/templates/gig/gig_detail.html index d7a3c2a8..2b5267ad 100644 --- a/templates/gig/gig_detail.html +++ b/templates/gig/gig_detail.html @@ -254,6 +254,14 @@ {% include "gig/watching.html" %} {% endif %} + {% if the_user_is_band_admin %} + + + + {% trans "Take Attendance" %} + + + {% endif %} {% if gig.plans_locked %}