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 ==================================================
# ====================================================================
# Three forms mirror the SiteReport / WorkerWarning patterns:
# Two forms mirror the SiteReport / WorkerWarning patterns:
# - AbsenceLogForm: standalone /absences/log/ with date-range support,
# 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
# 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
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
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 = []
for worker, d in self.expanded_pairs():
for wl in WorkLog.objects.filter(date=d, workers=worker).select_related('project'):
rows.append({
'worker_id': worker.id,
'worker_name': worker.name,
'date': d,
'work_log_id': wl.id,
'project_name': wl.project.name if wl.project else '',
})
for wl in wls:
for w in wl.workers.all():
if (w.id, wl.date) in pair_set:
rows.append({
'worker_id': w.id,
'worker_name': w.name,
'date': wl.date,
'work_log_id': wl.id,
'project_name': wl.project.name if wl.project else '',
})
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):
"""Edit one existing Absence. Lets admin correct worker/date as well
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 %}
<li class="mb-1">
{% 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 %}
<i class="far fa-circle me-1 text-muted"></i>
{% endif %}

View File

@ -1900,9 +1900,8 @@ class AbsenceUserQuerysetTests(TestCase):
# =============================================================================
# === 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
# - AbsenceQuickForm: minimal modal form on /attendance/log/
# - AbsenceEditForm: edit one existing absence
# =============================================================================

View File

@ -495,6 +495,35 @@ def index(request):
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 ===
# 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).
@ -693,17 +722,9 @@ def attendance_log(request):
worker_rates[str(w.id)] = str(w.daily_rate)
# 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_workers_map = {}
teams_qs = Team.objects.filter(active=True).prefetch_related('workers')
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
# team is selected from the dropdown. Key = team ID, Value = list of
# active worker IDs. Single query via the shared helper.
team_workers_map = _build_team_workers_map(user)
return render(request, 'core/attendance_log.html', {
'form': form,
@ -5418,18 +5439,10 @@ def absence_log(request):
# === 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
# [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.
# 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
# Single query via the shared `_build_team_workers_map` helper.
team_workers_map = _build_team_workers_map(request.user)
return render(request, 'core/absences/log.html', {
'form': form,
@ -5580,6 +5593,57 @@ def _create_absences_atomic(pairs, reason, is_paid, notes, user, worklog_removal
_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
def absence_list(request):
"""Filtered list of absences with pagination + reason badges.
@ -5597,8 +5661,9 @@ def absence_list(request):
# === ACCESS GATE ===
# Admins always pass. Supervisors pass if they supervise at least
# one team. Everyone else gets a 403 instead of an empty list, so
# it's obvious the page wasn't meant for them.
if not (is_admin(user) or user.supervised_teams.exists()):
# it's obvious the page wasn't meant for them. DRY: same gate logic
# as /absences/log/ — use the shared helper.
if not _user_can_log_absences(user):
return HttpResponseForbidden('Permission denied.')
# Base queryset — scoped to what this user is allowed to see, with
@ -5610,17 +5675,20 @@ def absence_list(request):
)
# === FILTERS ===
# Each filter is best-effort: bad input (non-numeric IDs, bad
# date strings, unknown reason keys) is silently ignored rather
# than 500-ing. Empty values are skipped.
# All filter logic lives in the shared `_apply_absence_filters` helper
# (used here AND by absence_export_csv to guarantee filter parity).
# 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')
team_id = request.GET.get('team')
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')
valid_reason_keys = dict(Absence.REASON_CHOICES)
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')
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 ===
# 25 per page — keeps the table snappy even with years of history.
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')
# ===========================================================
# FILTER BLOCK — DUPLICATED from absence_list above.
# Kept verbatim (same params, same order) so the CSV export
# 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)
# Filter parity with absence_list — both views call the same helper
# so adding a new filter param updates both in one place.
qs = _apply_absence_filters(qs, request)
resp = HttpResponse(content_type='text/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-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-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-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-other { background: var(--badge-neutral-bg, #6c757d); color: #fff; }
.badge-absence-other { background: #6c757d; color: #fff; }