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>
879 lines
38 KiB
Python
879 lines
38 KiB
Python
# === 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
|