From 864ae722c4b8b9b217ca4ab7c38a569fa76f217c Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Mon, 27 Apr 2026 02:29:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(site-report):=20structured=20site=20progre?= =?UTF-8?q?ss=20logging=20=E2=80=94=20Phase=20A.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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//edit/ (name: site_report_edit) - /site-report// (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) --- CLAUDE.md | 43 +++ core/admin.py | 16 + core/forms.py | 147 ++++++++++ core/migrations/0013_add_site_report.py | 34 +++ core/models.py | 92 ++++++ core/site_report_schema.py | 96 ++++++ core/templates/core/site_report_detail.html | 140 +++++++++ core/templates/core/site_report_edit.html | 181 ++++++++++++ core/templates/core/work_history.html | 25 +- core/tests.py | 305 ++++++++++++++++++++ core/urls.py | 7 + core/views.py | 172 ++++++++++- 12 files changed, 1255 insertions(+), 3 deletions(-) create mode 100644 core/migrations/0013_add_site_report.py create mode 100644 core/site_report_schema.py create mode 100644 core/templates/core/site_report_detail.html create mode 100644 core/templates/core/site_report_edit.html diff --git a/CLAUDE.md b/CLAUDE.md index 6914778..9d76848 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//edit/` | `site_report_edit` | Create-or-update the optional SiteReport for a WorkLog (auto-redirected here after `/attendance/log/` POST) | +| `/site-report//` | `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 | diff --git a/core/admin.py b/core/admin.py index 20026cf..7085cca 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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//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') diff --git a/core/forms.py b/core/forms.py index 36dc9bc..d15c677 100644 --- a/core/forms.py +++ b/core/forms.py @@ -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 diff --git a/core/migrations/0013_add_site_report.py b/core/migrations/0013_add_site_report.py new file mode 100644 index 0000000..d6b336d --- /dev/null +++ b/core/migrations/0013_add_site_report.py @@ -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'], + }, + ), + ] diff --git a/core/models.py b/core/models.py index dc4e30f..60893e2 100644 --- a/core/models.py +++ b/core/models.py @@ -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") diff --git a/core/site_report_schema.py b/core/site_report_schema.py new file mode 100644 index 0000000..431576d --- /dev/null +++ b/core/site_report_schema.py @@ -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 `` +# 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}, + } diff --git a/core/templates/core/site_report_detail.html b/core/templates/core/site_report_detail.html new file mode 100644 index 0000000..023d75f --- /dev/null +++ b/core/templates/core/site_report_detail.html @@ -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// +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 %} + +
+ + {# === Page header === #} +
+
+

+ + Site Report +

+ + {{ work_log.project.name }} — + {{ work_log.date|date:"l, d M Y" }} + {% if work_log.team %}({{ work_log.team.name }}){% endif %} + + {% if site_report.created_by %} +
+ 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 %} +
+ {% endif %} +
+ +
+ +
+
+ + {# === Conditions === #} +
+ Conditions +
+
+
+ Weather: + {% 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 %} + + {% endif %} +
+
+ Temperature: + {% 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 %} + not recorded + {% endif %} +
+
+ + {# === Counts === #} + {% if counts_display %} +
+ Counts +
+
+ {% for c in counts_display %} +
+
+
+
+ {{ c.value }} +
+
{{ c.label }}
+
+
+
+ {% endfor %} +
+ {% endif %} + + {# === Checks === #} + {% if checks_display %} +
+ Other +
+
    + {% for c in checks_display %} +
  • + {% if c.value %} + + {% else %} + + {% endif %} + {{ c.label }} +
  • + {% endfor %} +
+ {% endif %} + + {# === Free-form notes === #} + {% if site_report.notes %} +
+ Notes +
+
{{ site_report.notes }}
+ {% endif %} + +
+
+
+{% endblock %} diff --git a/core/templates/core/site_report_edit.html b/core/templates/core/site_report_edit.html new file mode 100644 index 0000000..17e517a --- /dev/null +++ b/core/templates/core/site_report_edit.html @@ -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//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 %} + +
+ + {# === Page header === #} +
+
+

+ + {% if is_creating %}Log Today's Work{% else %}Edit Site Report{% endif %} +

+ + {{ work_log.project.name }} — + {{ work_log.date|date:"l, d M Y" }} + {% if work_log.team %}({{ work_log.team.name }}){% endif %} + +
+ {# Skip = abandon this report (intentional) and go home. #} + {# Save the attendance, ship the day. #} + + Skip + +
+ + {# === Helpful note for the supervisor — only visible on first creation === #} + {% if is_creating %} +
+ + Logging the day's progress is optional. Skip if you're in a hurry — + the attendance entry is already saved. +
+ {% endif %} + + {# === The form === #} +
+
+
+ {% csrf_token %} + + {# --- Form-level error display --- #} + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} +
+ {% endif %} + + {# === SECTION 1 — Weather + Temperature === #} +
+
+ Conditions +
+ + {# --- Weather: icon-button row, picks one --- #} + {# Bootstrap btn-check + label pattern gives us large tap targets #} + {# without a custom JavaScript handler. #} +
+ +
+ {% for choice_value, choice_label in form.weather.field.choices %} + + + {% endfor %} +
+ {% if form.weather.errors %}
{{ form.weather.errors|first }}
{% endif %} +
+ + {# --- Temperature min/max: two short numeric fields --- #} +
+
+ + {{ form.temperature_min }} + {% if form.temperature_min.errors %}
{{ form.temperature_min.errors|first }}
{% endif %} +
+
+ + {{ form.temperature_max }} + {% if form.temperature_max.errors %}
{{ form.temperature_max.errors|first }}
{% endif %} +
+
+
+ + {# === 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. #} +
+
+ Counts (today's totals) +
+
+ {% for metric, bound_field in count_field_pairs %} +
+ + {{ bound_field }} + {% if bound_field.errors %}
{{ bound_field.errors|first }}
{% endif %} +
+ {% endfor %} +
+
+ + {# === SECTION 3 — Check metrics (booleans) === #} +
+
+ Other (tap to mark) +
+ {% for metric, bound_field in check_field_pairs %} +
+ {{ bound_field }} + +
+ {% endfor %} +
+ + {# === SECTION 4 — Free-form notes === #} +
+ + {{ form.notes }} + {% if form.notes.errors %}
{{ form.notes.errors|first }}
{% endif %} +
+ + {# === Actions === #} +
+ + Skip + + +
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/work_history.html b/core/templates/core/work_history.html index 44a7999..03a7c0a 100644 --- a/core/templates/core/work_history.html +++ b/core/templates/core/work_history.html @@ -440,7 +440,30 @@ {% for log in logs %} - {{ log.date }} + + {{ 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 %} + + + + {% else %} + + + + {% endif %} + {{ log.project.name }} {% if filtered_worker_obj %} diff --git a/core/tests.py b/core/tests.py index 28ec2d7..0fff471 100644 --- a/core/tests.py +++ b/core/tests.py @@ -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) diff --git a/core/urls.py b/core/urls.py index 9e2122a..c3246ff 100644 --- a/core/urls.py +++ b/core/urls.py @@ -25,6 +25,13 @@ urlpatterns = [ path('history//', views.work_log_payroll_detail, name='work_log_payroll_detail'), path('history//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//edit/', views.site_report_edit, name='site_report_edit'), + path('site-report//', 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'), diff --git a/core/views.py b/core/views.py index a1b0b5f..d4b79b3 100644 --- a/core/views.py +++ b/core/views.py @@ -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//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_" / "check_" — 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// + 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()