From 7f5e4c9c509e9d99d14f8fb269ce4dd452534d1a Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Sun, 17 May 2026 01:52:19 +0200 Subject: [PATCH] feat!: remove SiteReport / Log Today's Work feature (model, code, UI, tests) Drops core_sitereport via 0018_delete_sitereport. Knowledge preserved in docs/plans/2026-05-17-site-report-removed-capture.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- core/admin.py | 16 -- core/forms.py | 150 +---------- core/migrations/0018_delete_sitereport.py | 16 ++ core/models.py | 91 ------- 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 | 21 -- core/tests.py | 276 +------------------- core/urls.py | 7 - core/views.py | 173 +----------- 11 files changed, 30 insertions(+), 1137 deletions(-) create mode 100644 core/migrations/0018_delete_sitereport.py delete mode 100644 core/site_report_schema.py delete mode 100644 core/templates/core/site_report_detail.html delete mode 100644 core/templates/core/site_report_edit.html diff --git a/core/admin.py b/core/admin.py index 75c5553..007678e 100644 --- a/core/admin.py +++ b/core/admin.py @@ -4,7 +4,6 @@ from .models import ( PayrollRecord, Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem, WorkerCertificate, WorkerWarning, - SiteReport, Absence, ) @@ -151,21 +150,6 @@ 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 a473d5f..3e64c85 100644 --- a/core/forms.py +++ b/core/forms.py @@ -14,9 +14,8 @@ from .models import ( WorkLog, Project, Team, Worker, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem, WorkerCertificate, WorkerWarning, - SiteReport, Absence, + Absence, ) -from .site_report_schema import COUNT_METRICS, CHECK_METRICS # === FILE SIZE VALIDATOR === @@ -467,155 +466,10 @@ class ProjectForm(forms.ModelForm): return cleaned -# ============================================================= -# === SITE REPORT FORM === -# ============================================================= -# What it does: lets the on-site supervisor (or admin) record the -# day's progress alongside attendance — weather, temperature, free-form -# notes, and a flexible set of count + check metrics whose schema -# lives in core/site_report_schema.py. -# Why it's structured this way: -# The four model fields (weather, temperature_min/max, notes) are -# declared as a normal ModelForm. The METRICS portion is built -# dynamically in __init__() — one IntegerField per COUNT_METRIC and -# one BooleanField per CHECK_METRIC. On save() we serialize those -# into the JSON `metrics` blob in the canonical {'counts': {...}, -# 'checks': {...}} shape. -# -# The dynamic-fields approach means: when Konrad adds a new metric -# to core/site_report_schema.py, the form picks it up automatically -# on next page load. No form code change. No migration. The JSONField -# on the model just stores the new key. - -class SiteReportForm(forms.ModelForm): - """Mobile-first form for editing a SiteReport. - - Use as: `SiteReportForm(data, instance=site_report)` for edits, or - `SiteReportForm(data, work_log=work_log)` for new reports — the - work_log gets attached in save(commit=True). - """ - - class Meta: - model = SiteReport - fields = ['weather', 'temperature_min', 'temperature_max', 'notes'] - widgets = { - # min=-20 / max=60 covers any plausible South African - # construction site temp range. inputmode=numeric pulls up - # the numeric keypad on phones. - 'temperature_min': forms.NumberInput(attrs={ - 'min': -20, 'max': 60, 'inputmode': 'numeric', - 'placeholder': 'min °C', - 'class': 'form-control', - }), - 'temperature_max': forms.NumberInput(attrs={ - 'min': -20, 'max': 60, 'inputmode': 'numeric', - 'placeholder': 'max °C', - 'class': 'form-control', - }), - 'notes': forms.Textarea(attrs={ - 'rows': 3, - 'placeholder': 'What happened on site today (free-form)', - 'class': 'form-control', - }), - } - - def __init__(self, *args, **kwargs): - # Pull `work_log` out of kwargs before super().__init__() — it's - # only used during save() to attach a brand-new instance. - self._work_log = kwargs.pop('work_log', None) - super().__init__(*args, **kwargs) - - # === Style the weather field as a radio group (rendered as - # icon buttons in the template). Bootstrap-friendly classes so - # the row of choices wraps well on small screens. - self.fields['weather'].widget = forms.RadioSelect(attrs={'class': 'site-report-weather'}) - - # === Build the dynamic count + check fields === - # Counts → IntegerField (min=0). Pre-fill from existing JSON if editing. - existing_metrics = (self.instance.metrics or {}) if self.instance.pk else {} - existing_counts = existing_metrics.get('counts', {}) or {} - existing_checks = existing_metrics.get('checks', {}) or {} - - for metric in COUNT_METRICS: - field_name = f"count_{metric['key']}" - self.fields[field_name] = forms.IntegerField( - label=metric['label'], - min_value=0, - required=False, - initial=existing_counts.get(metric['key'], None), - widget=forms.NumberInput(attrs={ - 'min': 0, - 'inputmode': 'numeric', - 'placeholder': '0', - 'class': 'form-control site-report-count', - }), - ) - - # Checks → BooleanField. Initial value comes from the JSON; if - # the key is absent (older report, schema added later), default - # to False. - for metric in CHECK_METRICS: - field_name = f"check_{metric['key']}" - self.fields[field_name] = forms.BooleanField( - label=metric['label'], - required=False, - initial=bool(existing_checks.get(metric['key'], False)), - widget=forms.CheckboxInput(attrs={ - 'class': 'form-check-input site-report-check', - }), - ) - - def clean(self): - cleaned = super().clean() - # Validate temp range: if BOTH are set, min ≤ max. - # If only one is set, that's fine (supervisor only had a - # thermometer reading at midday, say). - tmin = cleaned.get('temperature_min') - tmax = cleaned.get('temperature_max') - if tmin is not None and tmax is not None and tmin > tmax: - raise ValidationError( - "Min temperature can't be higher than max temperature. " - "Did you swap them?" - ) - return cleaned - - def save(self, commit=True): - # First do the usual ModelForm save (without commit — we want - # to build the metrics dict first, set work_log if needed, THEN - # hit the DB once). - instance = super().save(commit=False) - - # Attach the parent WorkLog on first save (kwargs path). - if self._work_log is not None: - instance.work_log = self._work_log - - # Build the metrics JSON blob from the dynamic fields. Use the - # schema lists as the source of truth — that way removing a - # metric from the schema also removes it from new reports. - counts = {} - for metric in COUNT_METRICS: - field_name = f"count_{metric['key']}" - value = self.cleaned_data.get(field_name) - # Treat blank as 0 so historic reports + skipped fields - # behave consistently when summed. - counts[metric['key']] = int(value) if value is not None else 0 - - checks = {} - for metric in CHECK_METRICS: - field_name = f"check_{metric['key']}" - checks[metric['key']] = bool(self.cleaned_data.get(field_name, False)) - - instance.metrics = {'counts': counts, 'checks': checks} - - if commit: - instance.save() - return instance - - # ==================================================================== # === ABSENCE FORMS ================================================== # ==================================================================== -# Two forms mirror the SiteReport / WorkerWarning patterns: +# Two forms mirror the WorkerWarning form pattern: # - AbsenceLogForm: standalone /absences/log/ with date-range support, # team filter, worker checkbox list, conflict detection. # - AbsenceEditForm: edit one existing absence; can correct diff --git a/core/migrations/0018_delete_sitereport.py b/core/migrations/0018_delete_sitereport.py new file mode 100644 index 0000000..6e1f1b2 --- /dev/null +++ b/core/migrations/0018_delete_sitereport.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.7 on 2026-05-16 23:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0017_alter_payrolladjustment_type'), + ] + + operations = [ + migrations.DeleteModel( + name='SiteReport', + ), + ] diff --git a/core/models.py b/core/models.py index 7d2db7b..c68a3c6 100644 --- a/core/models.py +++ b/core/models.py @@ -179,97 +179,6 @@ 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 === diff --git a/core/site_report_schema.py b/core/site_report_schema.py deleted file mode 100644 index 431576d..0000000 --- a/core/site_report_schema.py +++ /dev/null @@ -1,96 +0,0 @@ -# === 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 deleted file mode 100644 index 53ec3b7..0000000 --- a/core/templates/core/site_report_detail.html +++ /dev/null @@ -1,140 +0,0 @@ -{% 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 deleted file mode 100644 index 17e517a..0000000 --- a/core/templates/core/site_report_edit.html +++ /dev/null @@ -1,181 +0,0 @@ -{% 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 c78ca58..32789fa 100644 --- a/core/templates/core/work_history.html +++ b/core/templates/core/work_history.html @@ -455,27 +455,6 @@ {{ 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 }} diff --git a/core/tests.py b/core/tests.py index b61f995..cf49eb6 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1619,278 +1619,6 @@ class PayrollDashboardAdjustmentAggregationTests(TestCase): ) -# ============================================================================ -# === 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') - - # ==================================================================== # === Worker Absence — Phase 1: Model layer ========================== # ==================================================================== @@ -2824,8 +2552,8 @@ class AbsenceAttendanceShortcutTests(TestCase): def test_default_attendance_submit_redirects_home(self): """A plain attendance submit (no next_action) now redirects to - the dashboard — the old forced Site Report redirect was removed - (SiteReport feature deleted 17 May 2026).""" + the dashboard — the old forced two-step "Log Today's Work" + redirect was removed (feature deleted 17 May 2026).""" resp = self.client.post(reverse('attendance_log'), data={ 'date': '2026-05-14', 'project': self.project.id, diff --git a/core/urls.py b/core/urls.py index 962fc41..4a9438e 100644 --- a/core/urls.py +++ b/core/urls.py @@ -25,13 +25,6 @@ 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 a0e708d..0bcc7a7 100644 --- a/core/views.py +++ b/core/views.py @@ -28,17 +28,14 @@ from django.conf import settings from .models import ( Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment, WorkerCertificate, WorkerWarning, - SiteReport, Absence, ) from .forms import ( AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm, ExpenseLineItemFormSet, WorkerForm, WorkerCertificateFormSet, WorkerWarningFormSet, TeamForm, ProjectForm, - SiteReportForm, AbsenceLogForm, ) -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. @@ -753,10 +750,10 @@ def attendance_log(request): if next_action == 'log_absences' and created_count: # Pre-fill the absence form with the same date / team / # project the user just used. We use the LAST log's date - # (matches the site_report_edit behaviour for date ranges: - # the supervisor lands on the most recent day). - # Use the local-scope variables we already have from the - # form's cleaned_data — no need to re-fetch the WorkLog. + # for date ranges (the supervisor lands on the most + # recent day). Use the local-scope variables we already + # have from the form's cleaned_data — no need to re-fetch + # the WorkLog. from urllib.parse import urlencode params = {'date': dates_to_log[-1].isoformat()} if team: @@ -765,9 +762,10 @@ def attendance_log(request): params['project'] = project.id return redirect(f"{reverse('absence_log')}?{urlencode(params)}") - # SiteReport feature removed (17 May 2026) — a successful - # attendance submit now simply returns to the dashboard; - # the success toast above already confirms what was logged. + # The "Log Today's Work" two-step flow was removed (17 May + # 2026) — a successful attendance submit now simply returns + # to the dashboard; the success toast above already confirms + # what was logged. return redirect('home') else: # Don't pre-fill the start date — force the user to pick one @@ -795,153 +793,6 @@ 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. @@ -952,15 +803,11 @@ 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.select_related('site_report') + logs = WorkLog.objects else: # Supervisors only see logs for their projects - logs = WorkLog.objects.select_related('site_report').filter( + logs = WorkLog.objects.filter( Q(supervisor=user) | Q(project__supervisors=user) ).distinct()