chore(absences): 7 polish follow-ups from code review

Small cleanups tracked in docs/plans/parked-work.md:

1. Delete dead AbsenceQuickForm class — Round C replaced the per-row
   ✗ modal paradigm with the "Submit + Log Absences" button, but the
   form class never got wired up. No view, URL, template, or test
   ever referenced it.
2. Single-query team_workers_map via shared _build_team_workers_map
   helper. Previously fired one SELECT per team because .filter(
   active=True) on a prefetched M2M bypasses the prefetch cache.
   Now uses Prefetch(to_attr='active_workers_cached'). Both
   attendance_log() and absence_log() use the same helper.
3. absence_list permission check now uses _user_can_log_absences
   instead of duplicating the same `is_admin OR supervised_teams`
   logic inline.
4. Drop misleading var(--badge-neutral-bg, …) wrapper in custom.css —
   the variable isn't declared so the fallback always wins. Use the
   hex directly.
5. conflicting_worklogs() N+1 → single query: was firing one SELECT
   per (worker, date) pair (25 queries on a 5×5 form). Now 2 queries
   total via .filter(date__in=…, workers__in=…) + Python-side pair
   set check.
6. Extract _apply_absence_filters helper — absence_list and
   absence_export_csv were duplicating the same 7-param filter block
   (with a TODO comment to factor it out). Now structurally enforced
   in one place; list view keeps the raw param read-back for
   template-context dropdown preselection.
7. Replace style="color: var(--badge-bonus-bg)" with class="text-success"
   on the paid-check icon in site_report_detail.html — same WCAG
   contrast bug we fixed on the absence templates (background colour
   used as foreground).

All 157 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-05-15 01:09:44 +02:00
parent 70fa085886
commit d1d3e15444
5 changed files with 146 additions and 159 deletions

View File

@ -610,12 +610,9 @@ class SiteReportForm(forms.ModelForm):
# ==================================================================== # ====================================================================
# === ABSENCE FORMS ================================================== # === ABSENCE FORMS ==================================================
# ==================================================================== # ====================================================================
# Three forms mirror the SiteReport / WorkerWarning patterns: # Two forms mirror the SiteReport / WorkerWarning patterns:
# - AbsenceLogForm: standalone /absences/log/ with date-range support, # - AbsenceLogForm: standalone /absences/log/ with date-range support,
# team filter, worker checkbox list, conflict detection. # team filter, worker checkbox list, conflict detection.
# - AbsenceQuickForm: minimal form for the "Mark Absent" modal on
# /attendance/log/ — worker + date come from URL/POST, form only
# asks for reason / paid / notes.
# - AbsenceEditForm: edit one existing absence; can correct # - AbsenceEditForm: edit one existing absence; can correct
# worker/date as well as the other fields. # worker/date as well as the other fields.
# #
@ -780,53 +777,46 @@ class AbsenceLogForm(forms.ModelForm):
Conflicts are warnings, NOT errors a worker might genuinely have Conflicts are warnings, NOT errors a worker might genuinely have
partial-day work + partial-day absence (e.g. sick leave that started partial-day work + partial-day absence (e.g. sick leave that started
mid-shift). The view shows these on a confirm screen so the admin mid-shift). The view shows these on a confirm screen so the admin
can review before proceeding.""" can review before proceeding.
PERF: single query for all candidate WorkLogs, then Python-side
filter by the actual (worker_id, date) pair set. Previously fired
one SELECT per (worker, date) pair (N+1 25 queries on a typical
5-worker × 5-day submission). Now: 2 queries total (WorkLog + its
workers prefetch) regardless of pair count.
"""
pairs = self.expanded_pairs()
if not pairs:
return []
# Build sets used as the outer filter (broad SQL match) AND the
# post-filter pair check (narrow Python match). The outer filter
# may match WorkLogs that include OTHER workers on those dates,
# so we still verify each (worker_id, date) against pair_set.
workers = {w for w, _ in pairs}
dates = {d for _, d in pairs}
pair_set = {(w.id, d) for w, d in pairs}
wls = (
WorkLog.objects
.filter(date__in=dates, workers__in=workers)
.select_related('project')
.prefetch_related('workers')
.distinct()
)
rows = [] rows = []
for worker, d in self.expanded_pairs(): for wl in wls:
for wl in WorkLog.objects.filter(date=d, workers=worker).select_related('project'): for w in wl.workers.all():
rows.append({ if (w.id, wl.date) in pair_set:
'worker_id': worker.id, rows.append({
'worker_name': worker.name, 'worker_id': w.id,
'date': d, 'worker_name': w.name,
'work_log_id': wl.id, 'date': wl.date,
'project_name': wl.project.name if wl.project else '', 'work_log_id': wl.id,
}) 'project_name': wl.project.name if wl.project else '',
})
return rows return rows
class AbsenceQuickForm(forms.ModelForm):
"""Minimal form for the ✗ Mark Absent modal on /attendance/log/.
Worker and date come from URL/POST context (the row the admin clicked
on), so this form only asks for the three fields still missing:
reason / is_paid / notes."""
class Meta:
model = Absence
# `project` is optional — the modal may be opened from a worker row
# that already has a current project context (e.g. quick-mark from
# /attendance/log/), in which case the view can pre-fill it.
fields = ['project', 'reason', 'is_paid', 'notes']
widgets = {
'project': forms.Select(attrs={'class': 'form-select'}),
'reason': forms.Select(attrs={'class': 'form-select'}),
'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'notes': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}),
}
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
self.fields['project'].required = False
# Supervisor scope: project dropdown only shows their assigned projects.
# Admin / staff sees every active project.
if user is not None and not (user.is_staff or user.is_superuser):
self.fields['project'].queryset = Project.objects.filter(
active=True, supervisors=user,
)
else:
self.fields['project'].queryset = Project.objects.filter(active=True)
class AbsenceEditForm(forms.ModelForm): class AbsenceEditForm(forms.ModelForm):
"""Edit one existing Absence. Lets admin correct worker/date as well """Edit one existing Absence. Lets admin correct worker/date as well
as the other fields (in case the absence was logged against the wrong as the other fields (in case the absence was logged against the wrong

View File

@ -116,7 +116,7 @@ might want to scan multiple at once.
{% for c in checks_display %} {% for c in checks_display %}
<li class="mb-1"> <li class="mb-1">
{% if c.value %} {% if c.value %}
<i class="fas fa-check-circle me-1" style="color: var(--badge-bonus-bg);"></i> <i class="fas fa-check-circle me-1 text-success"></i>
{% else %} {% else %}
<i class="far fa-circle me-1 text-muted"></i> <i class="far fa-circle me-1 text-muted"></i>
{% endif %} {% endif %}

View File

@ -1900,9 +1900,8 @@ class AbsenceUserQuerysetTests(TestCase):
# ============================================================================= # =============================================================================
# === ABSENCE FORM TESTS (Task 3) === # === ABSENCE FORM TESTS (Task 3) ===
# Tests for the three form classes added in core/forms.py: # Tests for the form classes added in core/forms.py:
# - AbsenceLogForm: standalone /absences/log/ with date-range + multi-worker # - AbsenceLogForm: standalone /absences/log/ with date-range + multi-worker
# - AbsenceQuickForm: minimal modal form on /attendance/log/
# - AbsenceEditForm: edit one existing absence # - AbsenceEditForm: edit one existing absence
# ============================================================================= # =============================================================================

View File

@ -495,6 +495,35 @@ def index(request):
return render(request, 'core/index.html', context) return render(request, 'core/index.html', context)
# === TEAM → ACTIVE WORKERS MAP HELPER ===
# Used by both attendance_log() and absence_log() to feed the team-filter
# JavaScript ({team_id: [active_worker_id, ...]}) — picking a team in the
# dropdown auto-checks (or filters to) that team's active workers.
#
# PERF: Prefetch with `to_attr=` is critical. The naive version did
# `prefetch_related('workers')` then `.filter(active=True)` inside the
# loop — but `.filter()` on a prefetched M2M bypasses the prefetch cache
# and re-queries the DB, giving an N+1 (one SELECT per team). The
# `Prefetch(..., queryset=Worker.objects.filter(active=True),
# to_attr='active_workers_cached')` pattern moves the active-filter into
# the prefetch query itself — one SELECT total for all teams' workers.
def _build_team_workers_map(user):
"""Return {team_id: [active_worker_id, ...]} for the team-filter JS.
Single query via Prefetch(to_attr=). Admins see every active team;
supervisors see only the teams they themselves supervise."""
teams_qs = Team.objects.filter(active=True).prefetch_related(
Prefetch(
'workers',
queryset=Worker.objects.filter(active=True),
to_attr='active_workers_cached',
)
)
if not is_admin(user):
teams_qs = teams_qs.filter(supervisor=user)
return {t.id: [w.id for w in t.active_workers_cached] for t in teams_qs}
# === ATTENDANCE LOGGING === # === ATTENDANCE LOGGING ===
# This is where supervisors log which workers showed up to work each day. # This is where supervisors log which workers showed up to work each day.
# Supports logging a single day or a date range (e.g. a whole week). # Supports logging a single day or a date range (e.g. a whole week).
@ -693,17 +722,9 @@ def attendance_log(request):
worker_rates[str(w.id)] = str(w.daily_rate) worker_rates[str(w.id)] = str(w.daily_rate)
# Build team→workers mapping so the JS can auto-check workers when a # Build team→workers mapping so the JS can auto-check workers when a
# team is selected from the dropdown. Key = team ID, Value = list of worker IDs. # team is selected from the dropdown. Key = team ID, Value = list of
team_workers_map = {} # active worker IDs. Single query via the shared helper.
teams_qs = Team.objects.filter(active=True).prefetch_related('workers') team_workers_map = _build_team_workers_map(user)
if not is_admin(user):
# Supervisors only see their own teams
teams_qs = teams_qs.filter(supervisor=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/attendance_log.html', { return render(request, 'core/attendance_log.html', {
'form': form, 'form': form,
@ -5418,18 +5439,10 @@ def absence_log(request):
# === TEAM → WORKERS MAP for the in-page team filter (Fix A1, May 2026) === # === 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 → # 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 # [active_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. # worker rows whose worker_id is not in the selected team's list.
# Supervisors only see their own teams; admins see every active team. # Single query via the shared `_build_team_workers_map` helper.
team_workers_map = {} team_workers_map = _build_team_workers_map(request.user)
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', { return render(request, 'core/absences/log.html', {
'form': form, 'form': form,
@ -5580,6 +5593,57 @@ def _create_absences_atomic(pairs, reason, is_paid, notes, user, worklog_removal
_sync_absence_payroll_adjustment(a) _sync_absence_payroll_adjustment(a)
def _apply_absence_filters(qs, request):
"""Apply the standard URL-param filters to an Absence queryset.
URL params: worker, team, project, reason, date_from, date_to, paid.
Each filter is best-effort bad input (non-numeric IDs, malformed
date strings, unknown reason keys) is silently ignored rather than
500-ing.
Used by both absence_list and absence_export_csv so filter parity is
structurally enforced adding a new param here updates both views
in one place."""
worker_id = request.GET.get('worker')
team_id = request.GET.get('team')
project_id = request.GET.get('project')
reasons = request.GET.getlist('reason')
date_from = request.GET.get('date_from')
date_to = request.GET.get('date_to')
paid = request.GET.get('paid')
if worker_id and worker_id.isdigit():
qs = qs.filter(worker_id=worker_id)
if team_id and team_id.isdigit():
qs = qs.filter(worker__teams__id=team_id).distinct()
if project_id and project_id.isdigit():
# Direct FK filter (since 0015_absence_project) — matches
# absences explicitly linked to this project.
qs = qs.filter(project_id=project_id)
# Whitelist reason keys against REASON_CHOICES so an attacker can't
# sneak an arbitrary string into the SQL filter.
valid_reason_keys = dict(Absence.REASON_CHOICES)
reasons = [r for r in reasons if r in valid_reason_keys]
if reasons:
qs = qs.filter(reason__in=reasons)
# parse_date() returns None for malformed input — filter is skipped.
# Without this guard, Django's date coercion raises ValidationError
# and the request 500s on URL fuzzing.
if date_from:
parsed = parse_date(date_from)
if parsed:
qs = qs.filter(date__gte=parsed)
if date_to:
parsed = parse_date(date_to)
if parsed:
qs = qs.filter(date__lte=parsed)
if paid == 'paid':
qs = qs.filter(is_paid=True)
elif paid == 'unpaid':
qs = qs.filter(is_paid=False)
return qs
@login_required @login_required
def absence_list(request): def absence_list(request):
"""Filtered list of absences with pagination + reason badges. """Filtered list of absences with pagination + reason badges.
@ -5597,8 +5661,9 @@ def absence_list(request):
# === ACCESS GATE === # === ACCESS GATE ===
# Admins always pass. Supervisors pass if they supervise at least # Admins always pass. Supervisors pass if they supervise at least
# one team. Everyone else gets a 403 instead of an empty list, so # one team. Everyone else gets a 403 instead of an empty list, so
# it's obvious the page wasn't meant for them. # it's obvious the page wasn't meant for them. DRY: same gate logic
if not (is_admin(user) or user.supervised_teams.exists()): # as /absences/log/ — use the shared helper.
if not _user_can_log_absences(user):
return HttpResponseForbidden('Permission denied.') return HttpResponseForbidden('Permission denied.')
# Base queryset — scoped to what this user is allowed to see, with # Base queryset — scoped to what this user is allowed to see, with
@ -5610,17 +5675,20 @@ def absence_list(request):
) )
# === FILTERS === # === FILTERS ===
# Each filter is best-effort: bad input (non-numeric IDs, bad # All filter logic lives in the shared `_apply_absence_filters` helper
# date strings, unknown reason keys) is silently ignored rather # (used here AND by absence_export_csv to guarantee filter parity).
# than 500-ing. Empty values are skipped. # We still read the raw param values out here so the template context
# below can preselect the matching dropdown/checkbox options.
qs = _apply_absence_filters(qs, request)
# Raw param read-back for template preselection (NOT for filtering —
# the helper handles that). Multi-value reason filter (Fix A2, May 2026):
# template renders reasons as a checkbox list sharing name="reason",
# so the querystring carries ?reason=sick&reason=annual on multi-select.
# getlist() pulls them ALL; whitelist against REASON_CHOICES.
worker_id = request.GET.get('worker') worker_id = request.GET.get('worker')
team_id = request.GET.get('team') team_id = request.GET.get('team')
project_id = request.GET.get('project') project_id = request.GET.get('project')
# 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') reasons = request.GET.getlist('reason')
valid_reason_keys = dict(Absence.REASON_CHOICES) valid_reason_keys = dict(Absence.REASON_CHOICES)
reasons = [r for r in reasons if r in valid_reason_keys] reasons = [r for r in reasons if r in valid_reason_keys]
@ -5628,39 +5696,6 @@ def absence_list(request):
date_to = request.GET.get('date_to') date_to = request.GET.get('date_to')
paid = request.GET.get('paid') paid = request.GET.get('paid')
if worker_id and worker_id.isdigit():
qs = qs.filter(worker_id=worker_id)
if team_id and team_id.isdigit():
qs = qs.filter(worker__teams__id=team_id).distinct()
if project_id and project_id.isdigit():
# Direct FK filter — was previously a transitive join via
# worker__work_logs__project_id, which was a workaround for not
# having Absence.project. Now that the FK exists, filter on it
# directly: matches absences explicitly linked to this project.
# (An absence whose worker happens to have worked on the project
# before but with a NULL absence.project will no longer appear —
# which is the correct behaviour: filter by what the absence
# says, not by adjacent activity.)
qs = qs.filter(project_id=project_id)
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)
# and the request 500s — a tiny URL-fuzzing footgun.
if date_from:
parsed = parse_date(date_from)
if parsed:
qs = qs.filter(date__gte=parsed)
if date_to:
parsed = parse_date(date_to)
if parsed:
qs = qs.filter(date__lte=parsed)
if paid == 'paid':
qs = qs.filter(is_paid=True)
elif paid == 'unpaid':
qs = qs.filter(is_paid=False)
# === PAGINATION === # === PAGINATION ===
# 25 per page — keeps the table snappy even with years of history. # 25 per page — keeps the table snappy even with years of history.
paginator = Paginator(qs, 25) paginator = Paginator(qs, 25)
@ -5802,48 +5837,9 @@ def absence_export_csv(request):
qs = _absence_user_queryset(request.user).select_related('worker', 'logged_by', 'project') qs = _absence_user_queryset(request.user).select_related('worker', 'logged_by', 'project')
# =========================================================== # Filter parity with absence_list — both views call the same helper
# FILTER BLOCK — DUPLICATED from absence_list above. # so adding a new filter param updates both in one place.
# Kept verbatim (same params, same order) so the CSV export qs = _apply_absence_filters(qs, request)
# honours the list page's filter URL exactly. If a future
# change adds a filter to the list view, mirror it here too.
# Follow-up TODO: factor into a `_apply_absence_filters(qs, request)`
# helper so the two views can't drift apart.
# ===========================================================
worker_id = request.GET.get('worker')
team_id = request.GET.get('team')
project_id = request.GET.get('project')
# 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')
if worker_id and worker_id.isdigit():
qs = qs.filter(worker_id=worker_id)
if team_id and team_id.isdigit():
qs = qs.filter(worker__teams__id=team_id).distinct()
if project_id and project_id.isdigit():
# Direct FK filter — was previously worker__work_logs__project_id
# (a workaround for not having Absence.project). Now uses the
# direct FK. Mirrors absence_list above for filter parity.
qs = qs.filter(project_id=project_id)
if reasons:
qs = qs.filter(reason__in=reasons)
if date_from:
parsed = parse_date(date_from)
if parsed:
qs = qs.filter(date__gte=parsed)
if date_to:
parsed = parse_date(date_to)
if parsed:
qs = qs.filter(date__lte=parsed)
if paid == 'paid':
qs = qs.filter(is_paid=True)
elif paid == 'unpaid':
qs = qs.filter(is_paid=False)
resp = HttpResponse(content_type='text/csv') resp = HttpResponse(content_type='text/csv')
resp['Content-Disposition'] = 'attachment; filename="absences.csv"' resp['Content-Disposition'] = 'attachment; filename="absences.csv"'

View File

@ -2195,8 +2195,10 @@ th.sortable.sorted .sort-arrow { opacity: 1; }
.badge-absence-sick { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); } .badge-absence-sick { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
.badge-absence-family { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); } .badge-absence-family { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
.badge-absence-annual { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); } .badge-absence-annual { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
.badge-absence-unpaid { background: var(--badge-neutral-bg, #6c757d); color: #fff; } /* unpaid/other use a neutral grey directly — no theme variation needed, */
/* #6c757d has enough contrast on both dark and light backgrounds. */
.badge-absence-unpaid { background: #6c757d; color: #fff; }
.badge-absence-iod { background: var(--badge-overtime-bg, #ffc107); color: var(--badge-overtime-fg, #000); } .badge-absence-iod { background: var(--badge-overtime-bg, #ffc107); color: var(--badge-overtime-fg, #000); }
.badge-absence-suspension { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); } .badge-absence-suspension { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
.badge-absence-absconded { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); } .badge-absence-absconded { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
.badge-absence-other { background: var(--badge-neutral-bg, #6c757d); color: #fff; } .badge-absence-other { background: #6c757d; color: #fff; }