38686-vm/core/forms.py
Konrad du Plessis 864ae722c4 feat(site-report): structured site progress logging — Phase A.1
Companion to attendance: capture WHAT was done on site each day,
alongside WHO worked. Optional 1:1 with WorkLog. Mobile-first form
auto-redirected from /attendance/log/ on success (with a Skip link).

Why this design (vs. extending WorkLog or per-project templates):

- Hybrid schema. Stable + queryable fields are real columns
  (`weather`, `temperature_min`, `temperature_max`, `notes`,
  `created_by`, `created_at`, `updated_at`). The METRICS that change
  per project / over time live in a single JSONField with shape
  `{counts: {key: int}, checks: {key: bool}}` — driven by
  `core/site_report_schema.py`. Adding a new metric is a one-line
  edit to that file, NO migration required. Old reports without the
  new key just render as 0 / unchecked.

- Two-step flow. Attendance form is unchanged; on successful POST
  the supervisor lands on `/site-report/<work_log_id>/edit/` for the
  most-recently-created log. They can fill in progress details
  (~30 sec on a phone) or click "Skip" to home. WorkLogs without a
  SiteReport are completely valid historic rows.

- Permission scope mirrors WorkLog access. Anyone who can see the
  parent log (admin / log's supervisor / project's supervisors) can
  see + edit its SiteReport. Wraps the existing pattern from
  `work_history()` in a small helper `_can_access_site_report()`.

What ships:

  Models:
    - SiteReport (1:1 → WorkLog, weather choices, IntegerField temps,
      JSONField metrics defaulting to {})
    - Migration 0013_add_site_report (pure CreateModel, no schema
      changes to existing tables)

  Schema:
    - core/site_report_schema.py (NEW) — single source of truth for
      the metric list. Currently 7 counts + 4 checks per Konrad's
      v1 spec. Helpers: get_count_keys, get_check_keys, label_for,
      empty_metrics.

  Form:
    - SiteReportForm (in core/forms.py) — ModelForm with the four
      stable fields PLUS dynamic IntegerField/BooleanField per
      metric in __init__. save() serializes both halves into the
      JSON blob. clean() validates min ≤ max temperature.

  Views:
    - site_report_edit — create-or-update; stamps created_by on
      first save; preserves it on subsequent admin edits
    - site_report_detail — read-only display; 404 when no report
    - attendance_log redirect updated to two-step flow
    - _can_access_site_report — shared permission helper

  URLs:
    - /site-report/<work_log_id>/edit/  (name: site_report_edit)
    - /site-report/<work_log_id>/       (name: site_report_detail)

  Templates:
    - site_report_edit.html — mobile-first stack of inputs, weather
      as a chunky icon-button row (☀️ ☁️ 🌧️ ⛈️ 🥵 🥶 💨), counts in a
      2-col grid, checks as toggle switches, Notes textarea, Skip
      + Save buttons. Iterates pre-built (metric, bound_field)
      pairs from the view to avoid needing a new template filter.
    - site_report_detail.html — counts as accent-coloured value
      cards, checks as a check-list, weather + temp + notes + edit
      link.
    - work_history.html — added a small clipboard icon next to
      each row's date: filled (linked to detail) when a report
      exists, muted outline (linked to edit) when not. Click is
      event.stopPropagation()-ed so the row's payroll-modal
      handler doesn't also fire.

  Performance:
    - work_history queryset adds .select_related('site_report') so
      the new template indicator doesn't introduce an N+1.

  Admin:
    - SiteReport registered with raw_id_fields on work_log +
      created_by, list filters on weather + project + date.

  Tests (16 new, full suite 85/85):
    - SiteReportModelTests — defaults, 1:1 reverse accessor,
      arbitrary-key JSON round-trip
    - SiteReportFormTests — dynamic field generation, save
      serialisation, temp validation, instance pre-fill
    - SiteReportEditViewTests — admin GET/POST, project
      supervisor allowed, outsider supervisor 403, created_by
      preserved on subsequent admin edits
    - SiteReportDetailViewTests — 404 when absent, displays data
      when present
    - AttendanceLogRedirectsToSiteReportTests — confirms the
      two-step flow

  CLAUDE.md updates:
    - SiteReport added to "Key Models" with shape + reverse-accessor note
    - New "SiteReport metric schema" section near "UI-vs-DB
      naming drift" — explains the JSON-column-with-Python-source
      pattern, when it's safe, what NOT to do (rename a key with
      data), and where the keys appear across the codebase
    - URL Routes table gets the two new endpoints

What's NOT in this commit (deferred per the brainstorm plan):
  - JournalEntry model + manual web-entry UI (Phase A.2 — depends
    on Konrad's Q7 answer about Vi/recipient field)
  - Letterly inbound webhook (Phase B — integrations branch only,
    depends on Q5 sample payload)
  - Photos on site reports (Q9, defaulted to "future")
  - Per-project metric templates (Q4, defaulted to "same set for all v1")

Reference plan: ~/.claude/plans/prancy-painting-brook.md (local).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 02:29:33 +02:00

608 lines
26 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,
)
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