From 2ae9f340588b4ecc6f619189b4861499a466bd88 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Thu, 14 May 2026 21:44:47 +0200 Subject: [PATCH] feat(absences): team filter + multi-reason filter + dashboard quick action Three UX wins from checkpoint feedback: - Team selector on /absences/log/ now hides non-team workers (matches /attendance/log/ behavior). - /absences/ reason filter accepts multiple values (?reason=sick&reason=annual). Multi-checkbox dropdown UI. - Dashboard Quick Actions: added Log Absence card with fa-user-clock icon. 1 new regression test for multi-reason filtering. --- core/templates/core/absences/list.html | 30 +++++++++++++---- core/templates/core/absences/log.html | 43 ++++++++++++++++++------ core/templates/core/index.html | 7 ++++ core/tests.py | 14 ++++++++ core/views.py | 46 +++++++++++++++++++++----- 5 files changed, 115 insertions(+), 25 deletions(-) diff --git a/core/templates/core/absences/list.html b/core/templates/core/absences/list.html index 1e3a0ae..7e25a1b 100644 --- a/core/templates/core/absences/list.html +++ b/core/templates/core/absences/list.html @@ -67,13 +67,29 @@ has an inline delete form. CSV export button only shows for admin.
- - + {# === Reason filter — multi-checkbox dropdown (Fix A2, May 2026) === #} + {# All checkboxes share name="reason" so Django gets multiple values #} + {# in request.GET.getlist('reason') when the form submits. The wrapping #} + {#
diff --git a/core/templates/core/absences/log.html b/core/templates/core/absences/log.html index 3f26837..f683c32 100644 --- a/core/templates/core/absences/log.html +++ b/core/templates/core/absences/log.html @@ -103,7 +103,7 @@ them from the WorkLog).
{% for worker in form.workers %} -
+
{{ worker.tag }}
@@ -132,20 +132,43 @@ them from the WorkLog).
{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index 9615eab..50c0beb 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -172,6 +172,13 @@ Log Work + {# === LOG ABSENCE — quick path to /absences/log/ (Fix A3, May 2026) === #} + {# Same icon as the Resources menu entry so users have one mental model #} + {# for "absences = fa-user-clock". #} + + + Log Absence + Run Payroll diff --git a/core/tests.py b/core/tests.py index f8f4933..e9b8c91 100644 --- a/core/tests.py +++ b/core/tests.py @@ -2285,6 +2285,20 @@ class AbsenceListViewTests(TestCase): self.assertContains(resp, 'WA', html=False) self.assertNotContains(resp, 'WB', html=False) + def test_filter_by_multiple_reasons(self): + """?reason=sick&reason=annual → table shows BOTH workers. + + Regression test for Fix A2 (May 2026): the reason filter accepts + multiple values via request.GET.getlist('reason') and OR-unions + them with filter(reason__in=...). Without this, the second + ?reason= param overrides the first and only one worker would + appear. + """ + self.client.force_login(self.admin) + resp = self.client.get('/absences/?reason=sick&reason=annual') + self.assertContains(resp, 'WA', html=False) + self.assertContains(resp, 'WB', html=False) + def test_malformed_date_param_does_not_crash(self): """SECURITY: garbage in URL params must not 500. parse_date() returns None on invalid input — those filters get skipped. diff --git a/core/views.py b/core/views.py index e1c7220..65b99a0 100644 --- a/core/views.py +++ b/core/views.py @@ -5258,7 +5258,25 @@ def absence_log(request): else: form = AbsenceLogForm(user=request.user) - return render(request, 'core/absences/log.html', {'form': form}) + # === TEAM → WORKERS MAP for the in-page team filter (Fix A1, May 2026) === + # Mirrors the pattern in attendance_log(): build a dict of team_id → + # [worker_ids] and pass it as JSON so the template's JS can hide + # worker rows whose worker_id is not in the selected team's list. + # Supervisors only see their own teams; admins see every active team. + team_workers_map = {} + teams_qs = Team.objects.filter(active=True).prefetch_related('workers') + if not is_admin(request.user): + teams_qs = teams_qs.filter(supervisor=request.user) + for team in teams_qs: + active_worker_ids = list( + team.workers.filter(active=True).values_list('id', flat=True) + ) + team_workers_map[team.id] = active_worker_ids + + return render(request, 'core/absences/log.html', { + 'form': form, + 'team_workers_json': json.dumps(team_workers_map), + }) @login_required @@ -5408,7 +5426,14 @@ def absence_list(request): worker_id = request.GET.get('worker') team_id = request.GET.get('team') project_id = request.GET.get('project') - reason = request.GET.get('reason') + # Multi-value reason filter (Fix A2, May 2026): the template renders + # the reasons as a checkbox list, all sharing name="reason", so the + # querystring carries ?reason=sick&reason=annual on multi-select. + # getlist() pulls them ALL; we then whitelist against REASON_CHOICES + # so an attacker can't sneak an arbitrary string into the SQL filter. + reasons = request.GET.getlist('reason') + valid_reason_keys = dict(Absence.REASON_CHOICES) + reasons = [r for r in reasons if r in valid_reason_keys] date_from = request.GET.get('date_from') date_to = request.GET.get('date_to') paid = request.GET.get('paid') @@ -5419,8 +5444,8 @@ def absence_list(request): qs = qs.filter(worker__teams__id=team_id).distinct() if project_id and project_id.isdigit(): qs = qs.filter(worker__work_logs__project_id=project_id).distinct() - if reason and reason in dict(Absence.REASON_CHOICES): - qs = qs.filter(reason=reason) + if reasons: + qs = qs.filter(reason__in=reasons) # parse_date() returns None for malformed input (e.g. "not-a-date") # so the filter is simply skipped. Without this guard, Django's # date coercion raises ValidationError (NOT ValueError/TypeError) @@ -5474,7 +5499,9 @@ def absence_list(request): 'filter_worker': worker_id or '', 'filter_team': team_id or '', 'filter_project': project_id or '', - 'filter_reason': reason or '', + # Note: filter_reasons is a LIST (post-Fix-A2). Templates iterating + # this need {% if x in filter_reasons %}, not {% if filter_reasons == x %}. + 'filter_reasons': reasons, 'filter_date_from': date_from or '', 'filter_date_to': date_to or '', 'filter_paid': paid or '', @@ -5588,7 +5615,10 @@ def absence_export_csv(request): worker_id = request.GET.get('worker') team_id = request.GET.get('team') project_id = request.GET.get('project') - reason = request.GET.get('reason') + # Multi-value reason filter — kept in parity with absence_list (Fix A2). + reasons = request.GET.getlist('reason') + valid_reason_keys = dict(Absence.REASON_CHOICES) + reasons = [r for r in reasons if r in valid_reason_keys] date_from = request.GET.get('date_from') date_to = request.GET.get('date_to') paid = request.GET.get('paid') @@ -5599,8 +5629,8 @@ def absence_export_csv(request): qs = qs.filter(worker__teams__id=team_id).distinct() if project_id and project_id.isdigit(): qs = qs.filter(worker__work_logs__project_id=project_id).distinct() - if reason and reason in dict(Absence.REASON_CHOICES): - qs = qs.filter(reason=reason) + if reasons: + qs = qs.filter(reason__in=reasons) if date_from: parsed = parse_date(date_from) if parsed: