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>
This commit is contained in:
Konrad du Plessis 2026-04-27 02:29:33 +02:00
parent 3da039b74e
commit 864ae722c4
12 changed files with 1255 additions and 3 deletions

View File

@ -61,6 +61,7 @@ staticfiles/ — Collected static assets (Bootstrap, admin) — NOT in git (
- **ExpenseReceipt / ExpenseLineItem** — business expense records with VAT handling
- **WorkerCertificate** — per-worker certifications (Skills, PDP, First Aid, Medical, Work at Height) with `valid_until` expiry and optional document upload. Unique per (worker, cert_type). Has `is_expired` and `expires_soon` (≤30 days) properties.
- **WorkerWarning** — disciplinary records per worker with severity (verbal/written/final), reason, optional document. Ordered -date.
- **SiteReport** — optional 1:1 with `WorkLog`, captures what was DONE on site that day: weather, temperature_min/max (°C, IntegerField), free-form `notes`, and a flexible `metrics` JSONField with shape `{'counts': {key: int}, 'checks': {key: bool}}`. The metric KEYS live in `core/site_report_schema.py` (NOT in the model) — see the "SiteReport metric schema" section below for the rationale + how to add a new metric without a migration. Reverse accessor: `work_log.site_report` (1:1, raises DoesNotExist when absent — wrap with try/except or use `WorkLog.objects.filter(site_report__isnull=False)`).
### Schema name-drifts to remember
Fields / accessors that differ from what you'd guess. Each has bitten multiple
@ -127,6 +128,46 @@ string comparisons across ~30 source locations.
- Loans have automated repayment deductions during payroll processing
- Cascading deletes use SET_NULL for supervisors/teams to preserve historical data
## SiteReport metric schema (Apr 2026) — flexible JSON, single Python source
**Why this pattern exists:** `SiteReport.metrics` is a `JSONField` with
shape `{'counts': {key: int}, 'checks': {key: bool}}`. The KEYS aren't
modelled as columns — they live in a single Python file
`core/site_report_schema.py`. To add a new metric (e.g. `cables_pulled`),
append one dict to `COUNT_METRICS` or `CHECK_METRICS` and redeploy. **No
database migration required.** The form auto-renders the new field; old
reports without that key just show 0 / unchecked.
**Why that's safe:**
- The form (`SiteReportForm` in `core/forms.py`) iterates the schema
lists at `__init__` time and builds dynamic `IntegerField` /
`BooleanField` per metric. Form state ↔ JSON blob via `save()`.
- Old data is never touched. Removing a metric from the schema means
the form stops rendering it; the JSON still contains the historic
value. `label_for(key)` falls back to the snake_case key when a
retired metric is shown on the detail page.
- The metric LABEL is rendered via `label_for(key)` (in
`core/site_report_schema.py`) so renaming a label is also a one-line
edit (and **doesn't break old data** because the key is unchanged).
**What to NEVER do:** rename a metric `key` that already has data —
historic reports will silently lose their value for that metric. If a
key MUST change, write a one-off Django data migration that walks
every `SiteReport` and renames the JSON key.
**Where the metric KEYS show up:**
- `core/site_report_schema.py` — the source of truth (COUNT_METRICS, CHECK_METRICS, helpers)
- `core/forms.py::SiteReportForm.__init__` — reads the lists, builds dynamic form fields
- `core/forms.py::SiteReportForm.save` — serializes form data into the JSON blob
- `core/templates/core/site_report_edit.html` — iterates `count_field_pairs` / `check_field_pairs` from the view
- `core/templates/core/site_report_detail.html` — iterates `counts_display` / `checks_display` from the view
**The two-step flow:** after `attendance_log` POST creates one or more
WorkLogs, the view redirects to `site_report_edit` for the most recent
log. The form has a "Skip" link to home — site reports are entirely
optional. WorkLogs without a SiteReport are completely valid historic
rows; they just don't show progress data on `/history/`.
## Payroll Constants
Defined at top of views.py — used in dashboard calculations and payment processing:
- **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan', 'Advance Payment']` — increase worker's net pay
@ -261,6 +302,8 @@ numbers on hot pages.
| `/attendance/log/` | `attendance_log` | Log daily work with date range support |
| `/history/` | `work_history` | Work logs table with filters |
| `/history/export/` | `export_work_log_csv` | Download filtered logs as CSV |
| `/site-report/<work_log_id>/edit/` | `site_report_edit` | Create-or-update the optional SiteReport for a WorkLog (auto-redirected here after `/attendance/log/` POST) |
| `/site-report/<work_log_id>/` | `site_report_detail` | Read-only view of the SiteReport (404 if none — use the edit URL to create) |
| `/workers/export/` | `export_workers_csv` | Admin: export all workers to CSV |
| `/workers/` | `worker_list` | Admin: friendly worker list with search + status filter |
| `/workers/new/` | `worker_edit` | Admin: blank worker-create form |

View File

@ -4,6 +4,7 @@ from .models import (
PayrollRecord, Loan, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem,
WorkerCertificate, WorkerWarning,
SiteReport,
)
@admin.register(UserProfile)
@ -112,6 +113,21 @@ class LoanAdmin(admin.ModelAdmin):
list_filter = ('active', 'date', 'worker')
search_fields = ('worker__name', 'reason')
@admin.register(SiteReport)
class SiteReportAdmin(admin.ModelAdmin):
"""Admin view for daily site progress reports.
`metrics` is a JSONField Django renders it as a textarea blob,
which is fine for low-frequency admin edits. The friendly
/site-report/<id>/edit/ form is the primary entry point.
"""
list_display = ('work_log', 'weather', 'temperature_min', 'temperature_max', 'created_by', 'created_at')
list_filter = ('weather', 'created_at', 'work_log__project')
search_fields = ('notes', 'work_log__project__name', 'work_log__supervisor__username')
raw_id_fields = ('work_log', 'created_by')
readonly_fields = ('created_at', 'updated_at')
@admin.register(PayrollAdjustment)
class PayrollAdjustmentAdmin(admin.ModelAdmin):
list_display = ('worker', 'type_display', 'amount', 'date')

View File

@ -14,7 +14,9 @@ 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 ===
@ -458,3 +460,148 @@ class ProjectForm(forms.ModelForm):
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

View File

@ -0,0 +1,34 @@
# Generated by Django 5.2.7 on 2026-04-27 00:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_alter_payrolladjustment_type_display_labels'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SiteReport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('weather', models.CharField(blank=True, choices=[('', ''), ('sunny', 'Sunny'), ('cloudy', 'Cloudy / Overcast'), ('rain', 'Rain'), ('storm', 'Storm'), ('hot', 'Hot'), ('cold', 'Cold'), ('windy', 'Windy')], help_text='Dominant weather of the working day.', max_length=20)),
('temperature_min', models.IntegerField(blank=True, help_text='Coldest temperature seen during the working day, °C.', null=True)),
('temperature_max', models.IntegerField(blank=True, help_text='Hottest temperature seen during the working day, °C.', null=True)),
('notes', models.TextField(blank=True, help_text='Free-form prose. What happened, what was tricky, who came on site, etc.')),
('metrics', models.JSONField(blank=True, default=dict, help_text='Per-day operational metrics. Schema in core/site_report_schema.py.')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='site_reports_created', to=settings.AUTH_USER_MODEL)),
('work_log', models.OneToOneField(help_text='The WorkLog this site report belongs to (1:1).', on_delete=django.db.models.deletion.CASCADE, related_name='site_report', to='core.worklog')),
],
options={
'ordering': ['-work_log__date', '-created_at'],
},
),
]

View File

@ -158,6 +158,98 @@ class PayrollRecord(models.Model):
def __str__(self):
return f"{self.worker.name} - {self.date}"
class SiteReport(models.Model):
"""One-per-WorkLog optional report capturing what was DONE on site
that day: weather, temperature, free-form notes, and a flexible
`metrics` JSON blob (counts + booleans).
Why this is separate from WorkLog (1:1 with optional reverse link)
rather than fields on WorkLog itself:
* WorkLog is a payroll record the source of "who worked + got
paid for what." Bloating it with operational metrics couples
two unrelated concerns.
* Reports are optional. A WorkLog without a SiteReport is a
completely valid historic row; the supervisor just didn't fill
in progress data that day.
* The 1:1 reverse accessor `work_log.site_report` raises
DoesNotExist on absence wrap with hasattr() / try-except in
templates and views, OR use `WorkLog.objects.filter(
site_report__isnull=False)` to query "logs WITH a report."
Why `metrics` is a JSONField:
Konrad's metric set evolves over time (new construction phases,
different project types). A JSON blob lets us add/remove fields
via a one-line edit to core/site_report_schema.py no DB
migration, no risk to historic data. Old reports without a new
key just render as 0 or unchecked.
The blob has two top-level keys: `counts` (dict of
key->non-negative-int) and `checks` (dict of key->bool). Schema
lives in core/site_report_schema.py.
"""
# Common, universally-applicable weather descriptors. Stored as the
# canonical short string; rendered via get_weather_display().
# Empty string = "not recorded" (the form leaves the radio blank).
WEATHER_CHOICES = [
('', ''),
('sunny', 'Sunny'),
('cloudy', 'Cloudy / Overcast'),
('rain', 'Rain'),
('storm', 'Storm'),
('hot', 'Hot'),
('cold', 'Cold'),
('windy', 'Windy'),
]
work_log = models.OneToOneField(
WorkLog, on_delete=models.CASCADE, related_name='site_report',
help_text='The WorkLog this site report belongs to (1:1).',
)
weather = models.CharField(
max_length=20, choices=WEATHER_CHOICES, blank=True,
help_text='Dominant weather of the working day.',
)
# Two integers (Celsius). Both optional — supervisor can fill in
# only one, or neither. We use IntegerField rather than DecimalField
# because a phone keyboard whole-degree entry is faster + thermometers
# at construction sites typically aren't precise to a decimal anyway.
temperature_min = models.IntegerField(
null=True, blank=True,
help_text='Coldest temperature seen during the working day, °C.',
)
temperature_max = models.IntegerField(
null=True, blank=True,
help_text='Hottest temperature seen during the working day, °C.',
)
notes = models.TextField(
blank=True,
help_text='Free-form prose. What happened, what was tricky, who came on site, etc.',
)
# JSONField is supported on MySQL 5.7+ and SQLite 3.9+ (via Django's
# native JSONField since 3.1). The default `default=dict` ensures
# `report.metrics` is never None — always at least an empty dict —
# which simplifies the form-rendering and detail-rendering code.
metrics = models.JSONField(
default=dict, blank=True,
help_text='Per-day operational metrics. Schema in core/site_report_schema.py.',
)
# Who filled it in. Usually the supervisor of the parent WorkLog.
# SET_NULL so deactivating an old supervisor's User account doesn't
# cascade-delete their historic reports.
created_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name='site_reports_created',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-work_log__date', '-created_at']
def __str__(self):
return f"Site Report — {self.work_log}"
class Loan(models.Model):
# === LOAN TYPE ===
# 'loan' = traditional loan (created via "New Loan")

View File

@ -0,0 +1,96 @@
# === core/site_report_schema.py ===
# Single source of truth for the on-site progress metric set used by
# SiteReport.metrics (a JSONField). Edit this file to add/remove/rename
# metrics — NO database migration required, the JSONField stores
# whatever keys you save.
#
# Why this file exists:
# The SiteReport model has structured fields (weather, temperature,
# notes, supervisor, timestamps) AND a JSON `metrics` blob. The blob
# stores per-day operational counts and checkboxes (e.g. how many
# plinths were cast today, was steel tied, did the team go to town
# for materials). These metrics will EVOLVE over time as different
# construction phases come up — and adding a new column for every
# change would mean a migration, a deploy, and a risk of breaking
# historic data.
#
# How to evolve:
# - Add a new metric: append a dict to COUNT_METRICS or CHECK_METRICS
# with a unique snake_case `key` and a human `label`. Done. The
# form renders it on next page load. Old reports just don't have
# that key in their JSON — which is fine, they render as 0 / unchecked.
# - Rename a label: change the `label` value only — the `key` stays
# so old data still maps to the same metric.
# - Retire a metric: delete it from this list. Old reports keep their
# data in the JSON (it's just no longer rendered in the form). If
# you want to fully purge it from the DB, write a one-off management
# command — the JSON is just a Python dict at the storage layer.
#
# How NOT to use this file:
# - Do NOT change a `key` of a metric that has data — historic reports
# will silently lose their value for that metric. If a key really
# must change, write a data migration that walks every SiteReport
# and renames the JSON key.
# === COUNT METRICS ===
# Numeric counts. Form renders each as a `<input type="number" min="0">`
# with a numeric keyboard on mobile. Default value is 0 (or blank) —
# blank is treated the same as 0 when summing for reports.
COUNT_METRICS = [
{'key': 'plinth_holes_dug', 'label': 'Plinth holes dug'},
{'key': 'plinths_cast', 'label': 'Plinths cast'},
{'key': 'plinths_deshuttered', 'label': 'Plinths de-shuttered'},
{'key': 'tables_set_out', 'label': 'Tables set out'},
{'key': 'boxes_placed', 'label': 'Boxes placed'},
{'key': 'shutter_boxes_leveled', 'label': 'Shutter boxes leveled'},
{'key': 'steel_placed', 'label': 'Steel placed'},
]
# === CHECK METRICS ===
# Boolean flags. Form renders each as a large-tap-target checkbox.
# Default value is False (unchecked) — absence in the JSON means False.
CHECK_METRICS = [
{'key': 'steel_tied', 'label': 'Steel tied / built'},
{'key': 'boxes_cleaned', 'label': 'Boxes cleaned'},
{'key': 'town_run', 'label': 'Town for materials / food'},
{'key': 'rain_delay', 'label': 'Rain delay'},
]
def get_count_keys():
"""Return the list of count-metric keys in display order.
Useful for: rendering the form, validating posted data, summing
counts across many reports for a project dashboard.
"""
return [m['key'] for m in COUNT_METRICS]
def get_check_keys():
"""Return the list of check-metric keys in display order."""
return [m['key'] for m in CHECK_METRICS]
def label_for(key):
"""Look up the human label for a metric key.
Returns the key itself (snake_case) if no match useful as a
fallback when displaying historic data whose key has since been
retired from the schema.
"""
for m in COUNT_METRICS + CHECK_METRICS:
if m['key'] == key:
return m['label']
return key
def empty_metrics():
"""Return a fresh metrics dict with all current keys at default values.
Counts default to 0, checks default to False. Use as a default for
new SiteReport instances OR as a template when comparing reports.
"""
return {
'counts': {m['key']: 0 for m in COUNT_METRICS},
'checks': {m['key']: False for m in CHECK_METRICS},
}

View File

@ -0,0 +1,140 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Site Report — {{ work_log.date|date:"d M Y" }} | FoxFitt{% endblock %}
{% block content %}
{% comment %}
Read-only display of a SiteReport. Reachable from:
- The work-history page indicator (📋 icon next to a log)
- Direct URL: /site-report/<work_log_id>/
Same permission scope as the edit view: admin OR supervisor of the
parent work log / project.
Designed to read well on a phone (column-stacked sections, compact
metric tables) AND on a desktop where someone reviewing weekly progress
might want to scan multiple at once.
{% endcomment %}
<div class="container py-4">
{# === Page header === #}
<div class="d-flex justify-content-between align-items-start gap-2 mb-3">
<div>
<h1 class="page-title mb-0">
<i class="fas fa-clipboard-check me-2" style="color: var(--accent);"></i>
Site Report
</h1>
<small class="text-muted">
{{ work_log.project.name }} —
{{ work_log.date|date:"l, d M Y" }}
{% if work_log.team %}({{ work_log.team.name }}){% endif %}
</small>
{% if site_report.created_by %}
<div class="text-muted" style="font-size: 0.8rem;">
Logged by {{ site_report.created_by.get_full_name|default:site_report.created_by.username }}
· {{ site_report.created_at|date:"d M Y, H:i" }}
{% if site_report.updated_at and site_report.updated_at != site_report.created_at %}
· last edited {{ site_report.updated_at|date:"d M Y, H:i" }}
{% endif %}
</div>
{% endif %}
</div>
<div class="d-flex gap-2">
<a href="{% url 'site_report_edit' work_log.id %}" class="btn btn-outline-secondary btn-sm" title="Edit this report">
<i class="fas fa-pen fa-sm me-1"></i> Edit
</a>
<a href="{% url 'work_history' %}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-arrow-left fa-sm me-1"></i> Back
</a>
</div>
</div>
<div class="card">
<div class="card-body p-3 p-md-4">
{# === Conditions === #}
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
Conditions
</h6>
<div class="row g-3 mb-4">
<div class="col-12 col-md-6">
<strong>Weather:</strong>
{% if site_report.weather %}
{% if site_report.weather == 'sunny' %}☀️
{% elif site_report.weather == 'cloudy' %}☁️
{% elif site_report.weather == 'rain' %}🌧️
{% elif site_report.weather == 'storm' %}⛈️
{% elif site_report.weather == 'hot' %}🥵
{% elif site_report.weather == 'cold' %}🥶
{% elif site_report.weather == 'windy' %}💨
{% endif %}
{{ site_report.get_weather_display }}
{% else %}
<span class="text-muted"></span>
{% endif %}
</div>
<div class="col-12 col-md-6">
<strong>Temperature:</strong>
{% if site_report.temperature_min is not None or site_report.temperature_max is not None %}
{% if site_report.temperature_min is not None %}{{ site_report.temperature_min }}°C{% else %}—{% endif %}
/
{% if site_report.temperature_max is not None %}{{ site_report.temperature_max }}°C{% else %}—{% endif %}
{% else %}
<span class="text-muted">not recorded</span>
{% endif %}
</div>
</div>
{# === Counts === #}
{% if counts_display %}
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
Counts
</h6>
<div class="row g-2 mb-4">
{% for c in counts_display %}
<div class="col-6 col-md-4">
<div class="card" style="background: var(--bg-card-hover); border: 1px solid rgba(255,255,255,0.05);">
<div class="card-body p-2 text-center">
<div style="font-size: 1.5rem; font-weight: 700; color: var(--accent);">
{{ c.value }}
</div>
<div class="text-muted" style="font-size: 0.75rem;">{{ c.label }}</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# === Checks === #}
{% if checks_display %}
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
Other
</h6>
<ul class="list-unstyled mb-4">
{% for c in checks_display %}
<li class="mb-1">
{% if c.value %}
<i class="fas fa-check-circle me-1" style="color: var(--badge-bonus-bg);"></i>
{% else %}
<i class="far fa-circle me-1 text-muted"></i>
{% endif %}
<span {% if not c.value %}class="text-muted"{% endif %}>{{ c.label }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
{# === Free-form notes === #}
{% if site_report.notes %}
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
Notes
</h6>
<div class="mb-2" style="white-space: pre-wrap;">{{ site_report.notes }}</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,181 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Site Report — {{ work_log.date|date:"d M Y" }} | FoxFitt{% endblock %}
{% block content %}
{% comment %}
Mobile-first site-report edit form. Reachable two ways:
1. Auto-redirect after a successful /attendance/log/ submission (the
"two-step flow" — supervisor logs WHO worked, then lands here to
log WHAT was done).
2. Direct visit /site-report/<work_log_id>/edit/ (admin or supervisor
of the parent work log) — useful for backfilling a missed report.
The form is deliberately optional: a "Skip" link goes straight home so
supervisors who're in a hurry can ship the attendance log without the
progress data. WorkLogs without a SiteReport are completely valid.
Layout principles:
- Vertical stack of inputs (no multi-column on phone)
- Large tap-targets for the boolean checkboxes
- inputmode="numeric" on count + temperature fields → numeric keypad
- Weather rendered as a chunky icon-button row, not a dropdown
{% endcomment %}
<div class="container py-4">
{# === Page header === #}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="page-title mb-0">
<i class="fas fa-clipboard-check me-2" style="color: var(--accent);"></i>
{% if is_creating %}Log Today's Work{% else %}Edit Site Report{% endif %}
</h1>
<small class="text-muted">
{{ work_log.project.name }} —
{{ work_log.date|date:"l, d M Y" }}
{% if work_log.team %}({{ work_log.team.name }}){% endif %}
</small>
</div>
{# Skip = abandon this report (intentional) and go home. #}
{# Save the attendance, ship the day. #}
<a href="{% url 'home' %}"
class="btn btn-outline-secondary btn-sm"
title="Skip the progress report — attendance is already saved">
<i class="fas fa-forward fa-sm me-1"></i> Skip
</a>
</div>
{# === Helpful note for the supervisor — only visible on first creation === #}
{% if is_creating %}
<div class="alert alert-info py-2 mb-3" style="font-size: 0.875rem;">
<i class="fas fa-info-circle me-1"></i>
Logging the day's progress is optional. Skip if you're in a hurry —
the attendance entry is already saved.
</div>
{% endif %}
{# === The form === #}
<div class="card">
<div class="card-body p-3 p-md-4">
<form method="post" novalidate>
{% csrf_token %}
{# --- Form-level error display --- #}
{% if form.non_field_errors %}
<div class="alert alert-danger py-2">
{% for error in form.non_field_errors %}<div>{{ error }}</div>{% endfor %}
</div>
{% endif %}
{# === SECTION 1 — Weather + Temperature === #}
<div class="mb-4">
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
Conditions
</h6>
{# --- Weather: icon-button row, picks one --- #}
{# Bootstrap btn-check + label pattern gives us large tap targets #}
{# without a custom JavaScript handler. #}
<div class="mb-3">
<label class="form-label mb-1 fw-semibold">Weather</label>
<div class="btn-group flex-wrap" role="group" aria-label="Weather">
{% for choice_value, choice_label in form.weather.field.choices %}
<input type="radio"
class="btn-check"
name="weather"
id="weather-{{ choice_value|default:'none' }}"
value="{{ choice_value }}"
{% if form.weather.value == choice_value %}checked{% endif %}
autocomplete="off">
<label class="btn btn-outline-secondary btn-sm me-1 mb-1" for="weather-{{ choice_value|default:'none' }}">
{% if choice_value == 'sunny' %}☀️ Sunny
{% elif choice_value == 'cloudy' %}☁️ Cloudy
{% elif choice_value == 'rain' %}🌧️ Rain
{% elif choice_value == 'storm' %}⛈️ Storm
{% elif choice_value == 'hot' %}🥵 Hot
{% elif choice_value == 'cold' %}🥶 Cold
{% elif choice_value == 'windy' %}💨 Windy
{% else %}—{% endif %}
</label>
{% endfor %}
</div>
{% if form.weather.errors %}<div class="text-danger small mt-1">{{ form.weather.errors|first }}</div>{% endif %}
</div>
{# --- Temperature min/max: two short numeric fields --- #}
<div class="row g-2">
<div class="col-6">
<label for="{{ form.temperature_min.id_for_label }}" class="form-label fw-semibold">Min °C</label>
{{ form.temperature_min }}
{% if form.temperature_min.errors %}<div class="text-danger small mt-1">{{ form.temperature_min.errors|first }}</div>{% endif %}
</div>
<div class="col-6">
<label for="{{ form.temperature_max.id_for_label }}" class="form-label fw-semibold">Max °C</label>
{{ form.temperature_max }}
{% if form.temperature_max.errors %}<div class="text-danger small mt-1">{{ form.temperature_max.errors|first }}</div>{% endif %}
</div>
</div>
</div>
{# === SECTION 2 — Count metrics === #}
{# Iterates (metric, bound_field) tuples pre-built in the view #}
{# so we don't need a "form-by-variable-name" template filter. #}
<div class="mb-4">
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
Counts (today's totals)
</h6>
<div class="row g-2">
{% for metric, bound_field in count_field_pairs %}
<div class="col-6 col-md-4">
<label for="{{ bound_field.id_for_label }}" class="form-label fw-semibold mb-1" style="font-size: 0.875rem;">
{{ metric.label }}
</label>
{{ bound_field }}
{% if bound_field.errors %}<div class="text-danger small mt-1">{{ bound_field.errors|first }}</div>{% endif %}
</div>
{% endfor %}
</div>
</div>
{# === SECTION 3 — Check metrics (booleans) === #}
<div class="mb-4">
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
Other (tap to mark)
</h6>
{% for metric, bound_field in check_field_pairs %}
<div class="form-check form-switch mb-2" style="padding-left: 2.75em;">
{{ bound_field }}
<label class="form-check-label fw-semibold" for="{{ bound_field.id_for_label }}" style="font-size: 0.95rem;">
{{ metric.label }}
</label>
</div>
{% endfor %}
</div>
{# === SECTION 4 — Free-form notes === #}
<div class="mb-4">
<label for="{{ form.notes.id_for_label }}" class="form-label fw-semibold">
Notes
<small class="text-muted fw-normal">(optional)</small>
</label>
{{ form.notes }}
{% if form.notes.errors %}<div class="text-danger small mt-1">{{ form.notes.errors|first }}</div>{% endif %}
</div>
{# === Actions === #}
<div class="d-flex justify-content-between gap-2">
<a href="{% url 'home' %}" class="btn btn-outline-secondary">
<i class="fas fa-times me-1"></i> Skip
</a>
<button type="submit" class="btn btn-accent">
<i class="fas fa-check me-1"></i>
{% if is_creating %}Save Site Report{% else %}Update{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -440,7 +440,30 @@
<tbody>
{% for log in logs %}
<tr {% if is_admin %}class="work-log-row" data-log-id="{{ log.id }}" style="cursor: pointer;"{% endif %}>
<td class="ps-4 align-middle">{{ log.date }}</td>
<td class="ps-4 align-middle">
{{ log.date }}
{# Site-report indicator: clipboard icon links to the read-only #}
{# detail page if a report exists; muted "+ Add" link otherwise. #}
{# Click is event.stopPropagation()-ed so the row's payroll-modal #}
{# click handler doesn't also fire when supervisors click here. #}
{% if log.site_report %}
<a href="{% url 'site_report_detail' log.id %}"
onclick="event.stopPropagation()"
class="ms-1"
title="View site report"
style="color: var(--accent); text-decoration: none;">
<i class="fas fa-clipboard-check fa-sm"></i>
</a>
{% else %}
<a href="{% url 'site_report_edit' log.id %}"
onclick="event.stopPropagation()"
class="ms-1"
title="Add site report"
style="color: var(--text-tertiary); text-decoration: none; opacity: 0.6;">
<i class="far fa-clipboard fa-sm"></i>
</a>
{% endif %}
</td>
<td class="align-middle"><strong>{{ log.project.name }}</strong></td>
<td class="align-middle">
{% if filtered_worker_obj %}

View File

@ -1379,3 +1379,308 @@ class PayrollDashboardAdjustmentAggregationTests(TestCase):
"this fails with R1000 the project-attribution double-"
"count bug has reappeared.",
)
# ============================================================================
# === SITE REPORT TESTS ===
# Cover the SiteReport model + form + views (edit, detail) + the
# attendance-log redirect that lands the supervisor on the new report
# form. The model uses a JSONField for `metrics`, so we exercise the
# round-trip (save form -> JSON in DB -> form re-loads from JSON).
# ============================================================================
from core.models import SiteReport
from core.forms import SiteReportForm
class SiteReportModelTests(TestCase):
"""The SiteReport model itself — sanity checks for defaults +
1:1 reverse accessor behaviour."""
def setUp(self):
self.admin = User.objects.create_user(username='sr-admin', is_staff=True)
self.project = Project.objects.create(name='SR Project')
self.work_log = WorkLog.objects.create(
date=datetime.date(2026, 4, 27),
project=self.project,
supervisor=self.admin,
)
def test_metrics_defaults_to_empty_dict(self):
"""A fresh SiteReport with no metrics passed should have an
empty-dict default never None so template/view code can
safely call .get('counts', {}) without an AttributeError."""
report = SiteReport.objects.create(work_log=self.work_log)
self.assertEqual(report.metrics, {})
self.assertIsNotNone(report.metrics)
def test_reverse_accessor_does_not_exist_when_absent(self):
"""A WorkLog with no SiteReport: accessing `work_log.site_report`
raises DoesNotExist (1:1 reverse semantics). Templates/views
must wrap this access in try/except or hasattr()."""
with self.assertRaises(SiteReport.DoesNotExist):
self.work_log.site_report # noqa: B018 — the access itself is the test
def test_reverse_accessor_works_when_present(self):
"""Once a SiteReport is created, work_log.site_report returns it."""
report = SiteReport.objects.create(work_log=self.work_log, weather='sunny')
self.assertEqual(self.work_log.site_report, report)
self.assertEqual(self.work_log.site_report.weather, 'sunny')
def test_metrics_round_trip_with_arbitrary_keys(self):
"""JSONField accepts arbitrary keys — we don't enforce schema at
the DB layer; future schema changes won't reject historic data."""
report = SiteReport.objects.create(
work_log=self.work_log,
metrics={
'counts': {'plinths_cast': 8, 'old_metric_no_longer_in_schema': 3},
'checks': {'steel_tied': True},
},
)
report.refresh_from_db()
self.assertEqual(report.metrics['counts']['plinths_cast'], 8)
self.assertEqual(report.metrics['counts']['old_metric_no_longer_in_schema'], 3)
self.assertTrue(report.metrics['checks']['steel_tied'])
class SiteReportFormTests(TestCase):
"""SiteReportForm: dynamic field generation + JSON serialisation."""
def setUp(self):
self.admin = User.objects.create_user(username='srf-admin', is_staff=True)
self.project = Project.objects.create(name='SRF Project')
self.work_log = WorkLog.objects.create(
date=datetime.date(2026, 4, 27),
project=self.project,
supervisor=self.admin,
)
def test_form_dynamically_includes_count_and_check_fields(self):
"""The form's __init__ should attach one field per metric in
the schema this is what makes adding a new metric a one-line
edit to site_report_schema.py."""
from core.site_report_schema import COUNT_METRICS, CHECK_METRICS
form = SiteReportForm(work_log=self.work_log)
for m in COUNT_METRICS:
self.assertIn(f"count_{m['key']}", form.fields)
for m in CHECK_METRICS:
self.assertIn(f"check_{m['key']}", form.fields)
def test_form_save_serialises_metrics_into_json_blob(self):
"""Submitting count + check fields should produce a metrics
dict with 'counts' and 'checks' top-level keys, populated from
the form input. Blank counts 0; unchecked checks False."""
data = {
'weather': 'sunny',
'temperature_min': 18,
'temperature_max': 28,
'notes': 'Hot day, good progress',
'count_plinths_cast': 8,
'count_plinth_holes_dug': 12,
# other count fields left blank → should default to 0
'check_steel_tied': 'on', # checkbox 'on' = checked
# check_boxes_cleaned not posted → False
}
form = SiteReportForm(data=data, work_log=self.work_log)
self.assertTrue(form.is_valid(), msg=form.errors)
report = form.save()
self.assertEqual(report.weather, 'sunny')
self.assertEqual(report.temperature_min, 18)
self.assertEqual(report.temperature_max, 28)
self.assertIn('counts', report.metrics)
self.assertIn('checks', report.metrics)
self.assertEqual(report.metrics['counts']['plinths_cast'], 8)
self.assertEqual(report.metrics['counts']['plinth_holes_dug'], 12)
# Blank counts default to 0, not absent
self.assertEqual(report.metrics['counts']['boxes_placed'], 0)
self.assertTrue(report.metrics['checks']['steel_tied'])
self.assertFalse(report.metrics['checks']['boxes_cleaned'])
def test_form_validates_temp_min_max_relationship(self):
"""If both temps are set and min > max, the supervisor probably
swapped them surface a ValidationError rather than silently
accept it."""
data = {
'weather': '',
'temperature_min': 35,
'temperature_max': 18,
'notes': '',
}
form = SiteReportForm(data=data, work_log=self.work_log)
self.assertFalse(form.is_valid())
# The error is form-level (clean()), not field-level
self.assertTrue(any('Min temperature' in e for e in form.non_field_errors()))
def test_form_pre_fills_from_existing_metrics(self):
"""Editing an existing SiteReport should pre-populate the
dynamic fields from the JSON blob."""
existing = SiteReport.objects.create(
work_log=self.work_log,
weather='cloudy',
metrics={'counts': {'plinths_cast': 5}, 'checks': {'town_run': True}},
)
form = SiteReportForm(instance=existing)
self.assertEqual(form.fields['count_plinths_cast'].initial, 5)
self.assertTrue(form.fields['check_town_run'].initial)
class SiteReportEditViewTests(TestCase):
"""The edit view — permissions, GET behaviour, POST behaviour."""
def setUp(self):
# Two users: an admin (full access) and a supervisor of a
# DIFFERENT project (must be denied access to this project's
# work logs).
self.admin = User.objects.create_user(username='sre-admin', password='pw', is_staff=True)
self.supervisor = User.objects.create_user(username='sre-sup', password='pw')
self.outsider_supervisor = User.objects.create_user(username='sre-out', password='pw')
self.project = Project.objects.create(name='SRE Project')
self.project.supervisors.add(self.supervisor)
self.other_project = Project.objects.create(name='Other Project')
self.other_project.supervisors.add(self.outsider_supervisor)
self.work_log = WorkLog.objects.create(
date=datetime.date(2026, 4, 27),
project=self.project,
supervisor=self.supervisor,
)
def test_admin_get_returns_blank_form_for_new_report(self):
self.client.force_login(self.admin)
url = reverse('site_report_edit', kwargs={'work_log_id': self.work_log.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Log Today') # heading on the create variant
def test_admin_post_creates_site_report(self):
self.client.force_login(self.admin)
url = reverse('site_report_edit', kwargs={'work_log_id': self.work_log.id})
response = self.client.post(url, {
'weather': 'rain',
'temperature_min': '15',
'temperature_max': '22',
'notes': 'Wet day, stopped early',
'count_plinths_cast': '4',
'check_rain_delay': 'on',
})
self.assertEqual(response.status_code, 302) # redirect to home on success
self.assertEqual(SiteReport.objects.count(), 1)
report = SiteReport.objects.first()
self.assertEqual(report.work_log_id, self.work_log.id)
self.assertEqual(report.weather, 'rain')
self.assertEqual(report.created_by_id, self.admin.id)
self.assertEqual(report.metrics['counts']['plinths_cast'], 4)
self.assertTrue(report.metrics['checks']['rain_delay'])
def test_admin_post_updates_existing_report_without_changing_created_by(self):
# A report already exists, created by the supervisor.
SiteReport.objects.create(
work_log=self.work_log,
weather='sunny',
created_by=self.supervisor,
)
# Admin edits it later.
self.client.force_login(self.admin)
url = reverse('site_report_edit', kwargs={'work_log_id': self.work_log.id})
response = self.client.post(url, {
'weather': 'cloudy',
'temperature_min': '',
'temperature_max': '',
'notes': 'Admin edited',
})
self.assertEqual(response.status_code, 302)
report = SiteReport.objects.get(work_log=self.work_log)
self.assertEqual(report.weather, 'cloudy')
# created_by is preserved — the original author keeps credit
self.assertEqual(report.created_by_id, self.supervisor.id)
def test_project_supervisor_can_edit_their_own_projects_report(self):
self.client.force_login(self.supervisor)
url = reverse('site_report_edit', kwargs={'work_log_id': self.work_log.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_outsider_supervisor_is_forbidden(self):
"""A supervisor of a DIFFERENT project must not be able to
access this project's site reports."""
self.client.force_login(self.outsider_supervisor)
url = reverse('site_report_edit', kwargs={'work_log_id': self.work_log.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
class SiteReportDetailViewTests(TestCase):
"""The read-only detail page."""
def setUp(self):
self.admin = User.objects.create_user(username='srd-admin', password='pw', is_staff=True)
self.project = Project.objects.create(name='SRD Project')
self.work_log = WorkLog.objects.create(
date=datetime.date(2026, 4, 27),
project=self.project,
supervisor=self.admin,
)
def test_404_when_no_report_exists(self):
"""The detail page is for VIEWING reports — if there isn't one,
404 (the supervisor should use the edit URL to create one)."""
self.client.force_login(self.admin)
url = reverse('site_report_detail', kwargs={'work_log_id': self.work_log.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_displays_report_data_when_present(self):
SiteReport.objects.create(
work_log=self.work_log,
weather='sunny',
temperature_min=18,
temperature_max=32,
notes='Brief but visible note',
metrics={
'counts': {'plinths_cast': 7},
'checks': {'steel_tied': True},
},
)
self.client.force_login(self.admin)
url = reverse('site_report_detail', kwargs={'work_log_id': self.work_log.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# Spot-check that the values reach the rendered HTML
self.assertContains(response, 'Brief but visible note')
self.assertContains(response, 'Sunny')
# The count appears as a numeric in the count card
self.assertContains(response, '7')
class AttendanceLogRedirectsToSiteReportTests(TestCase):
"""The two-step flow: after submitting attendance, the user lands
on the site-report edit page for the most recently created log."""
def setUp(self):
self.admin = User.objects.create_user(username='atrr-admin', password='pw', is_staff=True)
self.project = Project.objects.create(name='AT Project')
self.worker = Worker.objects.create(
name='Test Worker',
id_number='AT1',
monthly_salary=Decimal('10000'),
)
def test_successful_attendance_post_redirects_to_site_report_edit(self):
"""The redirect target is the site-report form, not home — so
the supervisor lands somewhere they can immediately log progress."""
self.client.force_login(self.admin)
response = self.client.post(reverse('attendance_log'), {
'date': '2026-04-27',
'project': self.project.id,
'workers': [self.worker.id],
'overtime_amount': '0.00',
'notes': '',
})
self.assertEqual(response.status_code, 302)
# The redirect URL contains '/site-report/' and ends with '/edit/'
self.assertIn('/site-report/', response.url)
self.assertTrue(response.url.endswith('/edit/'),
msg=f"Expected redirect to .../edit/, got {response.url}")
# And exactly one work log was created
self.assertEqual(WorkLog.objects.count(), 1)

View File

@ -25,6 +25,13 @@ urlpatterns = [
path('history/<int:log_id>/', views.work_log_payroll_detail, name='work_log_payroll_detail'),
path('history/<int:log_id>/payroll/ajax/', views.work_log_payroll_ajax, name='work_log_payroll_ajax'),
# === SITE REPORT (DAILY PROGRESS) ===
# Companion to attendance: log WHAT was done on site (counts, checks,
# weather, free-form notes). Edit page is the redirect target after
# /attendance/log/ submission. Detail page is read-only.
path('site-report/<int:work_log_id>/edit/', views.site_report_edit, name='site_report_edit'),
path('site-report/<int:work_log_id>/', views.site_report_detail, name='site_report_detail'),
# CSV export — downloads all worker data (admin only)
path('workers/export/', views.export_workers_csv, name='export_workers_csv'),

View File

@ -27,12 +27,15 @@ from django.conf import settings
from .models import (
Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment,
WorkerCertificate, WorkerWarning,
SiteReport,
)
from .forms import (
AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm, ExpenseLineItemFormSet,
WorkerForm, WorkerCertificateFormSet, WorkerWarningFormSet,
TeamForm, ProjectForm,
SiteReportForm,
)
from .site_report_schema import COUNT_METRICS, CHECK_METRICS, label_for
# NOTE: render_to_pdf is NOT imported here at the top level.
# It's imported lazily inside process_payment() and create_receipt()
# to avoid crashing the entire app if xhtml2pdf is not installed on the server.
@ -572,6 +575,9 @@ def attendance_log(request):
# --- Create work logs ---
created_count = 0
skipped_count = 0
# Track the IDs of work logs we just created so we can redirect
# the supervisor to the site-report form for the most recent one.
created_log_ids = []
for log_date in dates_to_log:
# Check which workers already have a log on this date
@ -612,6 +618,7 @@ def attendance_log(request):
)
work_log.workers.set(workers_to_add)
created_count += 1
created_log_ids.append(work_log.id)
# Show success message
if created_count > 0:
@ -622,6 +629,16 @@ def attendance_log(request):
else:
messages.warning(request, 'No work logs created — all entries were conflicts.')
# Two-step flow: after attendance, send the supervisor to the
# site-report form so they can log progress + weather while it's
# fresh in their head. The form has a "Skip" link to home for
# supervisors who're in a hurry. If we created NO logs, fall
# back to the old behaviour and just go home.
if created_log_ids:
# Redirect to the report for the LAST created log (most
# recent date when a date range was used — typically today
# for single-day entries).
return redirect('site_report_edit', work_log_id=created_log_ids[-1])
return redirect('home')
else:
# Don't pre-fill the start date — force the user to pick one
@ -656,6 +673,153 @@ def attendance_log(request):
})
# =============================================================================
# === SITE REPORT (DAILY PROGRESS) ===
# Two-step companion to attendance logging. After a supervisor logs WHO
# worked today, they're redirected to log WHAT was done (counts, checks,
# weather, free-form notes). Optional — they can skip via the link in
# the form template.
# =============================================================================
def _can_access_site_report(user, work_log):
"""Permission check for SiteReport edit + detail views.
Anyone who can see the parent WorkLog can see/edit its site report:
- Admins (is_staff or is_superuser): all logs
- Supervisors: logs they own (supervisor=user) OR logs whose
project they're assigned to (project.supervisors contains user)
Mirrors the queryset filter in `work_history()` so the two stay in
sync. Returns True if the user is allowed; the calling view should
return HttpResponseForbidden if False.
"""
if is_admin(user):
return True
if work_log.supervisor_id == user.id:
return True
if work_log.project.supervisors.filter(id=user.id).exists():
return True
return False
@login_required
def site_report_edit(request, work_log_id):
"""Create-or-update a SiteReport for the given WorkLog.
URL: /site-report/<work_log_id>/edit/
Permission: admin, or the WorkLog's supervisor / project supervisor.
Behaviour:
- GET on a WorkLog WITHOUT an existing report blank form
- GET on a WorkLog WITH a report form pre-filled with current values
- POST save, flash a toast, redirect home
The form has a "Skip" link in the template that goes straight home.
"""
work_log = get_object_or_404(
WorkLog.objects.select_related('project', 'team', 'supervisor'),
id=work_log_id,
)
if not _can_access_site_report(request.user, work_log):
return HttpResponseForbidden(
"You don't have permission to edit this site report."
)
# Pull the existing report if there is one. The 1:1 reverse accessor
# raises DoesNotExist when absent, so we wrap with a try/except (a
# bit cleaner than hasattr() given the field name).
try:
site_report = work_log.site_report
except SiteReport.DoesNotExist:
site_report = None
if request.method == 'POST':
form = SiteReportForm(
request.POST,
instance=site_report, # None = create new
work_log=work_log, # only used when creating
)
if form.is_valid():
instance = form.save(commit=False)
# Stamp `created_by` only on first save — keep the original
# author even if an admin edits it later. updated_at is
# handled automatically by auto_now=True on the model.
if instance.pk is None:
instance.created_by = request.user
instance.save()
messages.success(
request,
f"Site report saved for {work_log.project.name} on {work_log.date:%d %b %Y}.",
)
return redirect('home')
# Form validation errors fall through to render(...) below
else:
form = SiteReportForm(instance=site_report, work_log=work_log)
# Build (metric_dict, bound_field) pairs for the template. The form's
# dynamic fields are named "count_<key>" / "check_<key>" — the
# template iterates these pairs rather than calling form[name] with
# a variable key (which Django templates can't do without a custom
# filter). Pre-building here keeps the template clean.
count_field_pairs = [
(m, form[f"count_{m['key']}"]) for m in COUNT_METRICS
]
check_field_pairs = [
(m, form[f"check_{m['key']}"]) for m in CHECK_METRICS
]
return render(request, 'core/site_report_edit.html', {
'form': form,
'work_log': work_log,
'site_report': site_report,
'count_field_pairs': count_field_pairs,
'check_field_pairs': check_field_pairs,
'is_creating': site_report is None,
})
@login_required
def site_report_detail(request, work_log_id):
"""Read-only view of a SiteReport.
URL: /site-report/<work_log_id>/
Permission: same scope as site_report_edit.
Behaviour:
- 404 if there's no report for this work log (use the edit URL
to create one the link in templates points to the right URL
based on whether a report exists).
"""
work_log = get_object_or_404(
WorkLog.objects.select_related('project', 'team', 'supervisor'),
id=work_log_id,
)
if not _can_access_site_report(request.user, work_log):
return HttpResponseForbidden(
"You don't have permission to view this site report."
)
site_report = get_object_or_404(SiteReport, work_log=work_log)
# Build a flat list of (label, value) pairs for the template — this
# is the easiest way to render historic JSON keys whose label might
# have been retired from the schema (label_for falls back to the key).
counts_display = []
for key, value in (site_report.metrics.get('counts', {}) or {}).items():
counts_display.append({'key': key, 'label': label_for(key), 'value': value})
checks_display = []
for key, value in (site_report.metrics.get('checks', {}) or {}).items():
checks_display.append({'key': key, 'label': label_for(key), 'value': bool(value)})
return render(request, 'core/site_report_detail.html', {
'work_log': work_log,
'site_report': site_report,
'counts_display': counts_display,
'checks_display': checks_display,
})
# === WORK LOG HISTORY ===
# Shows work logs in two modes: a table list or a monthly calendar grid.
# Supervisors only see their own projects. Admins see everything.
@ -666,11 +830,15 @@ def work_history(request):
user = request.user
# Start with base queryset
# NOTE: select_related('site_report') prevents an N+1 query when
# the template checks `log.site_report` for the indicator icon.
# The 1:1 reverse relation is lazy by default — without this hint,
# each row would issue a separate SELECT.
if is_admin(user):
logs = WorkLog.objects.all()
logs = WorkLog.objects.select_related('site_report')
else:
# Supervisors only see logs for their projects
logs = WorkLog.objects.filter(
logs = WorkLog.objects.select_related('site_report').filter(
Q(supervisor=user) | Q(project__supervisors=user)
).distinct()