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

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

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

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

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

What ships:

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

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

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

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

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

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

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

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

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

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

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

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

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

97 lines
4.0 KiB
Python

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