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>
97 lines
4.0 KiB
Python
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},
|
|
}
|