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 %}
-
{% 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: