From 541b8973c7260c40ce179439bfe565e90a50b447 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Fri, 12 Jun 2026 17:58:56 +0200 Subject: [PATCH] perf: kill per-row queries on the History tab and Batch Pay preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - paid_records (History tab) now prefetches work_logs + adjustments: the template shows a day-count and loops adjustments per row, which fired 2 queries per visible record (~100 on a long history). - batch_pay_preview replaces the per-worker get_worker_active_team() call (worker.teams.filter(...).first() — bypasses prefetch, 1 query per worker) with the same batched membership-dict pattern payroll_dashboard already uses, and reads the unpaid-adjustments check from the existing filtered prefetch instead of .exists(). Also includes (committed earlier in 25910b2 but noting for the record): the /report/ worker-breakdown loop's per-worker-per-type aggregates were replaced by one GROUP BY dict (audit fix #7). Co-Authored-By: Claude Fable 5 --- core/views.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/core/views.py b/core/views.py index 517082c..cd7c205 100644 --- a/core/views.py +++ b/core/views.py @@ -3094,9 +3094,13 @@ def payroll_dashboard(request): pending_adj_sub_total += worker_adj_sub # --- Payment history --- + # prefetch work_logs + adjustments: the History tab template shows + # "{{ record.work_logs.count }} days" and loops record.adjustments.all + # per row — without the prefetch that's 2 queries per visible record + # (audit fix #9, Jun 2026). paid_records = PayrollRecord.objects.select_related( 'worker' - ).order_by('-date', '-id') + ).prefetch_related('work_logs', 'adjustments').order_by('-date', '-id') # --- Recent payments total (last 60 days, inclusive) --- # 60-day window math: subtract 59 days, not 60. With `>=` that yields @@ -3952,8 +3956,22 @@ def batch_pay_preview(request): ), ).order_by('name') + # === Active team per worker — ONE batched membership query === + # get_worker_active_team() runs worker.teams.filter(...).first() per + # worker, which bypasses the prefetch cache and fired a query per row + # (audit fix #10, Jun 2026). Same dict pattern as payroll_dashboard. + active_team_by_id = {t.id: t for t in Team.objects.filter(active=True)} + worker_active_team = {} + for membership in Team.workers.through.objects.filter( + team_id__in=active_team_by_id.keys() + ).values('team_id', 'worker_id'): + wid = membership['worker_id'] + if wid in worker_active_team: + continue + worker_active_team[wid] = active_team_by_id[membership['team_id']] + for worker in active_workers: - team = get_worker_active_team(worker) + team = worker_active_team.get(worker.id) # --- In 'schedule' mode, skip workers without a pay schedule --- if mode == 'schedule': @@ -3966,7 +3984,9 @@ def batch_pay_preview(request): has_unpaid = True break if not has_unpaid: - has_unpaid = worker.adjustments.filter(payroll_record__isnull=True).exists() + # the adjustments prefetch above is already filtered to + # unpaid rows — bool() reads the cache, no extra query + has_unpaid = bool(worker.adjustments.all()) if has_unpaid: skipped.append({