38686-vm/core/forms.py
Konrad du Plessis d1d3e15444 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>
2026-05-15 01:09:44 +02:00

879 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# === FORMS ===
# Django form classes for the app.
# - AttendanceLogForm: daily work log creation with date ranges and conflict detection
# - PayrollAdjustmentForm: adding bonuses, deductions, overtime, and loan adjustments
# - ExpenseReceiptForm + ExpenseLineItemFormSet: expense receipt creation with dynamic line items
# - WorkerForm + WorkerCertificateFormSet + WorkerWarningFormSet: friendly
# worker management (alternative to the Django admin)
from django import forms
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.forms import inlineformset_factory
from .models import (
WorkLog, Project, Team, Worker, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem,
WorkerCertificate, WorkerWarning,
SiteReport, Absence,
)
from .site_report_schema import COUNT_METRICS, CHECK_METRICS
# === FILE SIZE VALIDATOR ===
# Reusable 5 MB ceiling for uploads (photos, IDs, certificates, warning docs).
# Keeps the MEDIA_ROOT from being filled with a single accidental 50 MB scan.
MAX_UPLOAD_SIZE = 5 * 1024 * 1024 # 5 MB
def validate_max_5mb(f):
"""Raise ValidationError if the uploaded file exceeds 5 MB."""
if f and hasattr(f, 'size') and f.size > MAX_UPLOAD_SIZE:
mb = f.size / (1024 * 1024)
raise ValidationError(
f'File is {mb:.1f} MB — maximum allowed is 5 MB. '
'Please reduce the file size (e.g. scan at a lower resolution) and try again.'
)
class AttendanceLogForm(forms.ModelForm):
"""
Form for logging daily worker attendance.
Extra fields (not on the WorkLog model):
- end_date: optional end date for logging multiple days at once
- include_saturday: whether to include Saturdays in a date range
- include_sunday: whether to include Sundays in a date range
The supervisor field is NOT shown on the form — it gets set automatically
in the view to whoever is logged in.
"""
# --- Extra fields for date range logging ---
# These aren't on the WorkLog model, they're only used by the form
end_date = forms.DateField(
required=False,
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
label='End Date',
help_text='Leave blank to log a single day'
)
include_saturday = forms.BooleanField(
required=False,
initial=False,
label='Include Saturdays',
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
include_sunday = forms.BooleanField(
required=False,
initial=False,
label='Include Sundays',
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
class Meta:
model = WorkLog
# Supervisor is NOT included — it gets set in the view automatically
fields = ['date', 'project', 'team', 'workers', 'overtime_amount', 'notes']
widgets = {
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'project': forms.Select(attrs={'class': 'form-select'}),
'team': forms.Select(attrs={'class': 'form-select'}),
'workers': forms.CheckboxSelectMultiple(attrs={'class': 'form-check-input'}),
'overtime_amount': forms.Select(attrs={'class': 'form-select'}),
'notes': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Any notes about the day...'
}),
}
def __init__(self, *args, **kwargs):
# Pop 'user' from kwargs so we can filter based on who's logged in
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# --- Supervisor filtering ---
# If the user is NOT an admin, they can only see:
# - Projects they're assigned to (via project.supervisors M2M)
# - Workers in teams they supervise
if self.user and not (self.user.is_staff or self.user.is_superuser):
# Only show projects this supervisor is assigned to
self.fields['project'].queryset = Project.objects.filter(
active=True,
supervisors=self.user
)
# Only show workers from teams this supervisor manages
supervised_teams = Team.objects.filter(supervisor=self.user, active=True)
self.fields['workers'].queryset = Worker.objects.filter(
active=True,
teams__in=supervised_teams
).distinct()
# Only show teams this supervisor manages
self.fields['team'].queryset = supervised_teams
else:
# Admins see everything
self.fields['workers'].queryset = Worker.objects.filter(active=True)
self.fields['project'].queryset = Project.objects.filter(active=True)
self.fields['team'].queryset = Team.objects.filter(active=True)
# Make team optional (it already is on the model, but make the form match)
self.fields['team'].required = False
# Force start date to be blank — don't pre-fill with today's date.
# Django 5.x auto-fills form fields from model defaults (default=timezone.now),
# but we want the user to consciously pick a date every time.
self.fields['date'].initial = None
def clean(self):
"""Validate the date range makes sense."""
cleaned_data = super().clean()
start_date = cleaned_data.get('date')
end_date = cleaned_data.get('end_date')
if start_date and end_date and end_date < start_date:
raise forms.ValidationError('End date cannot be before start date.')
return cleaned_data
class PayrollAdjustmentForm(forms.ModelForm):
"""
Form for adding/editing payroll adjustments (bonuses, deductions, etc.).
Business rule: A project is required for Overtime, Bonus, Deduction, and
Advance Payment types. Loan and Loan Repayment are worker-level (no project).
"""
class Meta:
model = PayrollAdjustment
fields = ['type', 'project', 'worker', 'amount', 'date', 'description']
widgets = {
'type': forms.Select(attrs={'class': 'form-select'}),
'project': forms.Select(attrs={'class': 'form-select'}),
'worker': forms.Select(attrs={'class': 'form-select'}),
'amount': forms.NumberInput(attrs={
'class': 'form-control',
'step': '0.01',
'min': '0.01'
}),
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'Reason for this adjustment...'
}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['project'].queryset = Project.objects.filter(active=True)
self.fields['project'].required = False
self.fields['worker'].queryset = Worker.objects.filter(active=True)
def clean(self):
"""Validate that project-required types have a project selected."""
cleaned_data = super().clean()
adj_type = cleaned_data.get('type', '')
project = cleaned_data.get('project')
# These types must have a project — they're tied to specific work
project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment')
if adj_type in project_required_types and not project:
self.add_error('project', 'A project must be selected for this adjustment type.')
return cleaned_data
# =============================================================================
# === EXPENSE RECEIPT FORM ===
# Used on the /receipts/create/ page.
# The form handles receipt header fields (vendor, date, payment method, VAT type).
# Line items are handled separately by the ExpenseLineItemFormSet below.
# =============================================================================
class ExpenseReceiptForm(forms.ModelForm):
"""
Form for the receipt header — vendor, date, payment method, VAT type.
Line items (products + amounts) are handled by ExpenseLineItemFormSet.
"""
class Meta:
model = ExpenseReceipt
fields = ['date', 'vendor_name', 'description', 'payment_method', 'vat_type']
widgets = {
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'vendor_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Vendor Name'
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': 'What was purchased and why...'
}),
'payment_method': forms.Select(attrs={'class': 'form-select'}),
# Radio buttons for VAT type — shown as 3 options side by side
'vat_type': forms.RadioSelect(attrs={'class': 'form-check-input'}),
}
# === LINE ITEM FORMSET ===
# A "formset" is a group of identical mini-forms — one per line item.
# inlineformset_factory creates it automatically from the parent-child relationship.
# - extra=1: start with 1 blank row
# - can_delete=True: allows removing rows (checks a hidden DELETE checkbox)
ExpenseLineItemFormSet = inlineformset_factory(
ExpenseReceipt, # Parent model
ExpenseLineItem, # Child model
fields=['product_name', 'amount'],
extra=1, # Show 1 blank row by default
can_delete=True, # Allow deleting rows
widgets={
'product_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Item Name'
}),
'amount': forms.NumberInput(attrs={
'class': 'form-control item-amount',
'step': '0.01',
'placeholder': '0.00'
}),
}
)
# =============================================================
# === WORKER MANAGEMENT FORMS ===
# =============================================================
class WorkerForm(forms.ModelForm):
"""Main worker edit form — covers all the flat fields on Worker.
Certifications and warnings are handled separately by the formsets
below (they have their own rows in their own tables).
"""
class Meta:
model = Worker
fields = [
'name', 'id_number', 'phone_number', 'monthly_salary',
'tax_number', 'uif_number', 'bank_name', 'bank_account_number',
'employment_date', 'active', 'notes',
'shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size',
'photo', 'id_document',
'has_drivers_license', 'drivers_license', 'drivers_license_code',
]
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'id_number': forms.TextInput(attrs={'class': 'form-control'}),
'phone_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '+27...'}),
'monthly_salary': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}),
# Banking & Tax
'tax_number': forms.TextInput(attrs={'class': 'form-control'}),
'uif_number': forms.TextInput(attrs={'class': 'form-control'}),
'bank_name': forms.TextInput(attrs={'class': 'form-control',
'placeholder': 'e.g. FNB, Standard Bank, Capitec'}),
'bank_account_number': forms.TextInput(attrs={'class': 'form-control'}),
'employment_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'shoe_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. 9 / 43'}),
'overall_top_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. L'}),
'pants_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. 34'}),
'tshirt_size': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. L'}),
'photo': forms.ClearableFileInput(attrs={'class': 'form-control'}),
'id_document': forms.ClearableFileInput(attrs={'class': 'form-control'}),
'has_drivers_license': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'drivers_license': forms.ClearableFileInput(attrs={'class': 'form-control'}),
'drivers_license_code': forms.TextInput(attrs={'class': 'form-control',
'placeholder': 'e.g. EB, C1'}),
}
def clean_photo(self):
f = self.cleaned_data.get('photo')
validate_max_5mb(f)
return f
def clean_id_document(self):
f = self.cleaned_data.get('id_document')
validate_max_5mb(f)
return f
def clean_drivers_license(self):
f = self.cleaned_data.get('drivers_license')
validate_max_5mb(f)
return f
class WorkerCertificateForm(forms.ModelForm):
"""Single certificate row. Used inside the formset — not rendered directly."""
class Meta:
model = WorkerCertificate
fields = ['cert_type', 'document', 'issued_date', 'valid_until', 'notes']
widgets = {
'cert_type': forms.Select(attrs={'class': 'form-select form-select-sm'}),
'document': forms.ClearableFileInput(attrs={'class': 'form-control form-control-sm'}),
'issued_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}),
'valid_until': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}),
'notes': forms.Textarea(attrs={'class': 'form-control form-control-sm', 'rows': 2}),
}
def clean_document(self):
f = self.cleaned_data.get('document')
validate_max_5mb(f)
return f
class WorkerWarningForm(forms.ModelForm):
"""Single warning row. Used inside the formset — not rendered directly."""
class Meta:
model = WorkerWarning
fields = ['date', 'severity', 'reason', 'description', 'document']
widgets = {
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control form-control-sm'}),
'severity': forms.Select(attrs={'class': 'form-select form-select-sm'}),
'reason': forms.TextInput(attrs={'class': 'form-control form-control-sm',
'placeholder': 'Short summary'}),
'description': forms.Textarea(attrs={'class': 'form-control form-control-sm', 'rows': 2,
'placeholder': 'Full context...'}),
'document': forms.ClearableFileInput(attrs={'class': 'form-control form-control-sm'}),
}
def clean_document(self):
f = self.cleaned_data.get('document')
validate_max_5mb(f)
return f
# === WORKER CERTIFICATE FORMSET ===
# extra=0: don't render blank rows by default — user clicks "+ Add" to create.
# can_delete: user can tick the delete checkbox to remove a cert on save.
WorkerCertificateFormSet = inlineformset_factory(
Worker, WorkerCertificate,
form=WorkerCertificateForm,
extra=0,
can_delete=True,
)
# === WORKER WARNING FORMSET ===
WorkerWarningFormSet = inlineformset_factory(
Worker, WorkerWarning,
form=WorkerWarningForm,
extra=0,
can_delete=True,
)
# =============================================================
# === TEAM & PROJECT MANAGEMENT FORMS ===
# =============================================================
# Friendly edit forms for Teams and Projects — alternative to Django
# admin. Both are simple ModelForms (no inline formsets — these models
# only have M2M relationships, handled by standard multi-select widgets).
def _supervisor_user_queryset():
"""Users eligible to supervise a team or project.
Returns ALL active users. Any active user can be picked as a supervisor
— the picker doesn't need to pre-filter by group or staff flags because
the app's `is_supervisor(user)` helper (in views.py) already grants
supervisor permissions to anyone assigned to a team/project FK/M2M,
regardless of their group membership.
Previously this filter required `is_staff`/`is_superuser` OR membership
in a "Work Logger" group, which was strictly more restrictive than the
permission model and hid field supervisors from the picker. Fix
(2026-04-23): trust the admin making the assignment; any active user
can be chosen, and the act of assignment is what confers supervisor
powers downstream.
Inactive users (`is_active=False`) are still excluded — deactivated
accounts should never appear in dropdowns.
"""
return User.objects.filter(is_active=True).order_by('username')
class TeamForm(forms.ModelForm):
"""Team edit form — covers every Team field plus the `workers` M2M."""
class Meta:
model = Team
fields = [
'name', 'supervisor', 'active',
'pay_frequency', 'pay_start_date',
'workers',
]
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'supervisor': forms.Select(attrs={'class': 'form-select'}),
'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'pay_frequency': forms.Select(attrs={'class': 'form-select'}),
'pay_start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
# CheckboxSelectMultiple is kinder for small worker lists; the
# template groups active/inactive visually via template logic.
'workers': forms.CheckboxSelectMultiple(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Supervisor dropdown — show any active user. The app's is_supervisor
# helper (views.py) grants supervisor powers to whoever is assigned
# here, regardless of group or staff flags, so the picker doesn't
# need to pre-filter by role. Only is_active=True users appear, so
# deactivated accounts are hidden from the dropdown.
self.fields['supervisor'].queryset = _supervisor_user_queryset()
self.fields['supervisor'].required = False
# Include inactive workers too — matches admin parity. The template
# badges inactive ones so users can tell at a glance.
self.fields['workers'].queryset = Worker.objects.all().order_by('-active', 'name')
self.fields['workers'].required = False
class ProjectForm(forms.ModelForm):
"""Project edit form — covers every Project field plus the `supervisors` M2M."""
class Meta:
model = Project
fields = [
'name', 'description', 'active',
'start_date', 'end_date',
'supervisors',
]
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'start_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'end_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'supervisors': forms.CheckboxSelectMultiple(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Project supervisors follow the same rule as team supervisors — admins
# or Work Loggers are eligible.
self.fields['supervisors'].queryset = _supervisor_user_queryset()
self.fields['supervisors'].required = False
def clean(self):
cleaned = super().clean()
start = cleaned.get('start_date')
end = cleaned.get('end_date')
if start and end and end < start:
raise ValidationError("End date must be on or after the start date.")
return cleaned
# =============================================================
# === SITE REPORT FORM ===
# =============================================================
# What it does: lets the on-site supervisor (or admin) record the
# day's progress alongside attendance — weather, temperature, free-form
# notes, and a flexible set of count + check metrics whose schema
# lives in core/site_report_schema.py.
# Why it's structured this way:
# The four model fields (weather, temperature_min/max, notes) are
# declared as a normal ModelForm. The METRICS portion is built
# dynamically in __init__() — one IntegerField per COUNT_METRIC and
# one BooleanField per CHECK_METRIC. On save() we serialize those
# into the JSON `metrics` blob in the canonical {'counts': {...},
# 'checks': {...}} shape.
#
# The dynamic-fields approach means: when Konrad adds a new metric
# to core/site_report_schema.py, the form picks it up automatically
# on next page load. No form code change. No migration. The JSONField
# on the model just stores the new key.
class SiteReportForm(forms.ModelForm):
"""Mobile-first form for editing a SiteReport.
Use as: `SiteReportForm(data, instance=site_report)` for edits, or
`SiteReportForm(data, work_log=work_log)` for new reports — the
work_log gets attached in save(commit=True).
"""
class Meta:
model = SiteReport
fields = ['weather', 'temperature_min', 'temperature_max', 'notes']
widgets = {
# min=-20 / max=60 covers any plausible South African
# construction site temp range. inputmode=numeric pulls up
# the numeric keypad on phones.
'temperature_min': forms.NumberInput(attrs={
'min': -20, 'max': 60, 'inputmode': 'numeric',
'placeholder': 'min °C',
'class': 'form-control',
}),
'temperature_max': forms.NumberInput(attrs={
'min': -20, 'max': 60, 'inputmode': 'numeric',
'placeholder': 'max °C',
'class': 'form-control',
}),
'notes': forms.Textarea(attrs={
'rows': 3,
'placeholder': 'What happened on site today (free-form)',
'class': 'form-control',
}),
}
def __init__(self, *args, **kwargs):
# Pull `work_log` out of kwargs before super().__init__() — it's
# only used during save() to attach a brand-new instance.
self._work_log = kwargs.pop('work_log', None)
super().__init__(*args, **kwargs)
# === Style the weather field as a radio group (rendered as
# icon buttons in the template). Bootstrap-friendly classes so
# the row of choices wraps well on small screens.
self.fields['weather'].widget = forms.RadioSelect(attrs={'class': 'site-report-weather'})
# === Build the dynamic count + check fields ===
# Counts → IntegerField (min=0). Pre-fill from existing JSON if editing.
existing_metrics = (self.instance.metrics or {}) if self.instance.pk else {}
existing_counts = existing_metrics.get('counts', {}) or {}
existing_checks = existing_metrics.get('checks', {}) or {}
for metric in COUNT_METRICS:
field_name = f"count_{metric['key']}"
self.fields[field_name] = forms.IntegerField(
label=metric['label'],
min_value=0,
required=False,
initial=existing_counts.get(metric['key'], None),
widget=forms.NumberInput(attrs={
'min': 0,
'inputmode': 'numeric',
'placeholder': '0',
'class': 'form-control site-report-count',
}),
)
# Checks → BooleanField. Initial value comes from the JSON; if
# the key is absent (older report, schema added later), default
# to False.
for metric in CHECK_METRICS:
field_name = f"check_{metric['key']}"
self.fields[field_name] = forms.BooleanField(
label=metric['label'],
required=False,
initial=bool(existing_checks.get(metric['key'], False)),
widget=forms.CheckboxInput(attrs={
'class': 'form-check-input site-report-check',
}),
)
def clean(self):
cleaned = super().clean()
# Validate temp range: if BOTH are set, min ≤ max.
# If only one is set, that's fine (supervisor only had a
# thermometer reading at midday, say).
tmin = cleaned.get('temperature_min')
tmax = cleaned.get('temperature_max')
if tmin is not None and tmax is not None and tmin > tmax:
raise ValidationError(
"Min temperature can't be higher than max temperature. "
"Did you swap them?"
)
return cleaned
def save(self, commit=True):
# First do the usual ModelForm save (without commit — we want
# to build the metrics dict first, set work_log if needed, THEN
# hit the DB once).
instance = super().save(commit=False)
# Attach the parent WorkLog on first save (kwargs path).
if self._work_log is not None:
instance.work_log = self._work_log
# Build the metrics JSON blob from the dynamic fields. Use the
# schema lists as the source of truth — that way removing a
# metric from the schema also removes it from new reports.
counts = {}
for metric in COUNT_METRICS:
field_name = f"count_{metric['key']}"
value = self.cleaned_data.get(field_name)
# Treat blank as 0 so historic reports + skipped fields
# behave consistently when summed.
counts[metric['key']] = int(value) if value is not None else 0
checks = {}
for metric in CHECK_METRICS:
field_name = f"check_{metric['key']}"
checks[metric['key']] = bool(self.cleaned_data.get(field_name, False))
instance.metrics = {'counts': counts, 'checks': checks}
if commit:
instance.save()
return instance
# ====================================================================
# === ABSENCE FORMS ==================================================
# ====================================================================
# Two forms mirror the SiteReport / WorkerWarning patterns:
# - AbsenceLogForm: standalone /absences/log/ with date-range support,
# team filter, worker checkbox list, conflict detection.
# - AbsenceEditForm: edit one existing absence; can correct
# worker/date as well as the other fields.
#
# IMPORTANT: these forms do NOT persist Absence rows themselves. They
# build cleaned data + helper outputs (expanded_pairs / conflicting_worklogs).
# The view layer (Tasks 4-6) is the single place that creates Absence rows
# and calls _sync_absence_payroll_adjustment(). Keeps form logic
# focused on input validation, not side effects.
class AbsenceLogForm(forms.ModelForm):
"""
Standalone form for /absences/log/. Supports date ranges and
multiple workers per submission. Validates conflicts; the view
consumes expanded_pairs() and conflicting_worklogs() to drive the
confirm/save flow.
"""
# --- Date range extras (mirrors AttendanceLogForm pattern) ---
# These aren't on the Absence model — they're form-only inputs the
# view uses to expand into multiple Absence rows.
end_date = forms.DateField(
required=False,
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
label='End Date',
help_text='Leave blank to log a single day.',
)
include_saturday = forms.BooleanField(
required=False, initial=False, label='Include Saturdays',
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
)
include_sunday = forms.BooleanField(
required=False, initial=False, label='Include Sundays',
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
)
# --- Worker picker ---
# Team narrows the worker list in the UI (JS filter); workers is the
# source of truth for who's being marked absent.
team = forms.ModelChoiceField(
queryset=Team.objects.filter(active=True),
required=False,
widget=forms.Select(attrs={'class': 'form-select'}),
help_text='Optional — narrows the worker list below.',
)
workers = forms.ModelMultipleChoiceField(
queryset=Worker.objects.filter(active=True),
widget=forms.CheckboxSelectMultiple,
)
class Meta:
model = Absence
# `project` slots in between date and reason — it's part of the
# "what happened" header, not a per-row notes detail. Optional.
fields = ['date', 'project', 'reason', 'is_paid', 'notes']
widgets = {
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'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': 3, 'class': 'form-control'}),
}
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
# Project is optional — admins can leave it blank for non-project
# absences (e.g. Annual Leave). When set + is_paid=True, the auto-
# created Bonus PayrollAdjustment will inherit it for cost-attribution.
self.fields['project'].required = False
# Supervisor scope: limit team + workers + project querysets to
# the user's reach. Admins (staff/superuser) keep the default
# "all active" lists.
if user is not None and not (user.is_staff or user.is_superuser):
self.fields['team'].queryset = (
Team.objects.filter(active=True, supervisor=user)
)
# Match AttendanceLogForm: only workers on ACTIVE supervised teams
# appear in the picker. Drops workers whose only supervised team is
# inactive — keeps the picker consistent with attendance logging.
self.fields['workers'].queryset = (
Worker.objects.filter(
active=True,
teams__supervisor=user,
teams__active=True,
).distinct()
)
# Project dropdown — only projects this supervisor is assigned to.
# Mirrors the AttendanceLogForm supervisor scoping pattern.
self.fields['project'].queryset = Project.objects.filter(
active=True, supervisors=user,
)
else:
# Admins see every active project.
self.fields['project'].queryset = Project.objects.filter(active=True)
def clean(self):
cleaned = super().clean()
start = cleaned.get('date')
end = cleaned.get('end_date')
# End date must come on/after start date (mirrors AttendanceLogForm).
if end and start and end < start:
self.add_error('end_date', 'End date must be on or after the start date.')
# Cache the expanded (worker, date) tuples so the view doesn't have
# to repeat the weekend-aware date walk.
self._pairs = self._expand_pairs(cleaned)
# Reject if ANY (worker, date) pair already has an Absence row.
# The DB has unique_together on (worker, date) — surface this as a
# friendly form error rather than letting it blow up at save time.
existing = []
for worker, d in self._pairs:
if Absence.objects.filter(worker=worker, date=d).exists():
existing.append(f'{worker.name} on {d:%d %b %Y}')
if existing:
self.add_error(
None,
f'Absence already exists for: {", ".join(existing)}. '
'Edit the existing record instead.'
)
return cleaned
def _expand_pairs(self, cleaned):
"""Build the (worker, date) tuple list from cleaned data, respecting
Sat/Sun toggles. Returns [] if no start date — defensive guard so
partial form errors don't crash the conflict check above."""
from datetime import timedelta
workers = cleaned.get('workers') or []
start = cleaned.get('date')
end = cleaned.get('end_date') or start
inc_sat = cleaned.get('include_saturday') or False
inc_sun = cleaned.get('include_sunday') or False
if not start:
return []
# Single-pass date walk — skip Sat/Sun unless toggled on.
days = []
d = start
while d <= end:
wd = d.weekday() # Mon=0, ..., Sat=5, Sun=6
if wd == 5 and not inc_sat:
d += timedelta(days=1)
continue
if wd == 6 and not inc_sun:
d += timedelta(days=1)
continue
days.append(d)
d += timedelta(days=1)
# Cartesian product: every selected worker on every kept day.
return [(w, d) for w in workers for d in days]
def expanded_pairs(self):
"""Return the (worker, date) tuples produced by clean(). Caller must
invoke is_valid() first — otherwise this returns []."""
return getattr(self, '_pairs', [])
def conflicting_worklogs(self):
"""Return a list of dicts describing (worker, date) pairs that have
an existing WorkLog. Each dict has: worker_id, worker_name, date,
work_log_id, project_name.
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.
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 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 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
person/day)."""
class Meta:
model = Absence
# `project` is editable — admins can add or change the project link
# after the fact. Optional (matches the model's blank=True, null=True).
fields = ['worker', 'date', 'project', 'reason', 'is_paid', 'notes']
widgets = {
'worker': forms.Select(attrs={'class': 'form-select'}),
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'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': 3, 'class': 'form-control'}),
}
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
# Default: every active worker. Admins (staff/superuser) keep this list.
self.fields['worker'].queryset = Worker.objects.filter(active=True)
# Project is optional — leave blank for non-project absences.
self.fields['project'].required = False
# Supervisor scope: when a non-admin opens the edit form, the worker
# dropdown is restricted to workers on their own active supervised
# teams. Prevents a supervisor from silently re-assigning an absence
# to a worker they don't supervise. Project dropdown also scoped
# to supervisor's assigned projects.
if user is not None and not (user.is_staff or user.is_superuser):
self.fields['worker'].queryset = Worker.objects.filter(
active=True,
teams__supervisor=user,
teams__active=True,
).distinct()
self.fields['project'].queryset = Project.objects.filter(
active=True, supervisors=user,
)
else:
self.fields['project'].queryset = Project.objects.filter(active=True)
def clean(self):
cleaned = super().clean()
worker = cleaned.get('worker')
d = cleaned.get('date')
if worker and d:
# Uniqueness check, excluding self (this is the edit form).
qs = Absence.objects.filter(worker=worker, date=d)
if self.instance and self.instance.pk:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
# Surface the error on the date field (where the user sees it),
# matching AbsenceLogForm's add_error style instead of raising.
self.add_error(
'date',
f'{worker.name} already has an absence on {d:%d %b %Y}.'
)
return cleaned