Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions gig/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
11 changes: 11 additions & 0 deletions gig/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
92 changes: 92 additions & 0 deletions gig/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

2 changes: 2 additions & 0 deletions gig/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@
path('<int:pk>/printallplans', views.PrintPlansView.as_view(), {'all':True}, name='gig-print-all-plans'),
path('<int:pk>/printconfirmedplans', views.PrintPlansView.as_view(), {'all':False}, name='gig-print-confirmed-plans'),
path('<int:pk>/printsetlist', views.PrintSetlistView.as_view(), name='gig-print-setlist'),
path('<int:pk>/attendance', views.AttendanceView.as_view(), name='gig-attendance'),

path('plan/<uuid:pk>/update/<int:val>', helpers.update_plan, name='plan-update'),
path('plan/<uuid:pk>/attendance', helpers.toggle_attendance, name='plan-attendance-toggle'),
path('plan/<uuid:pk>/feedback/<int:val>', helpers.update_plan_feedback, name='plan-update-feedback'),
path('plan/<uuid:pk>/comment', helpers.update_plan_comment, name='plan-update-comment'),
path('plan/<uuid:pk>/section/<int:val>', helpers.update_plan_section, name='plan-update-section'),
Expand Down
23 changes: 23 additions & 0 deletions gig/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions templates/gig/attendance_check.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% if plan.attended %}<i class="fas fa-check-circle"></i>{% else %}<i class="far fa-circle"></i>{% endif %}
15 changes: 15 additions & 0 deletions templates/gig/attendance_row.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% load i18n %}
<button type="button"
id="attendance-row-{{ plan.id }}"
class="att-row list-group-item list-group-item-action d-flex align-items-center text-left{% if plan.attended %} att-present{% endif %}"
data-name="{{ plan.assoc.member.display_name|lower }}"
data-attending="{% if plan.attending %}1{% else %}0{% endif %}"
hx-post="{% url 'plan-attendance-toggle' pk=plan.id %}"
hx-target="#att-check-{{ plan.id }}"
hx-swap="innerHTML">
<span class="att-check mr-3" id="att-check-{{ plan.id }}">{% include 'gig/attendance_check.html' %}</span>
<span class="att-name flex-grow-1">{{ plan.assoc.member.display_name }}</span>
<span class="att-meta text-muted small text-right">
<span class="att-status ml-1">{% include 'gig/plan_icon.html' with plan_value=plan.status %}</span>
</span>
</button>
Loading
Loading