feat(site-report): structured site progress logging — Phase A.1
Companion to attendance: capture WHAT was done on site each day,
alongside WHO worked. Optional 1:1 with WorkLog. Mobile-first form
auto-redirected from /attendance/log/ on success (with a Skip link).
Why this design (vs. extending WorkLog or per-project templates):
- Hybrid schema. Stable + queryable fields are real columns
(`weather`, `temperature_min`, `temperature_max`, `notes`,
`created_by`, `created_at`, `updated_at`). The METRICS that change
per project / over time live in a single JSONField with shape
`{counts: {key: int}, checks: {key: bool}}` — driven by
`core/site_report_schema.py`. Adding a new metric is a one-line
edit to that file, NO migration required. Old reports without the
new key just render as 0 / unchecked.
- Two-step flow. Attendance form is unchanged; on successful POST
the supervisor lands on `/site-report/<work_log_id>/edit/` for the
most-recently-created log. They can fill in progress details
(~30 sec on a phone) or click "Skip" to home. WorkLogs without a
SiteReport are completely valid historic rows.
- Permission scope mirrors WorkLog access. Anyone who can see the
parent log (admin / log's supervisor / project's supervisors) can
see + edit its SiteReport. Wraps the existing pattern from
`work_history()` in a small helper `_can_access_site_report()`.
What ships:
Models:
- SiteReport (1:1 → WorkLog, weather choices, IntegerField temps,
JSONField metrics defaulting to {})
- Migration 0013_add_site_report (pure CreateModel, no schema
changes to existing tables)
Schema:
- core/site_report_schema.py (NEW) — single source of truth for
the metric list. Currently 7 counts + 4 checks per Konrad's
v1 spec. Helpers: get_count_keys, get_check_keys, label_for,
empty_metrics.
Form:
- SiteReportForm (in core/forms.py) — ModelForm with the four
stable fields PLUS dynamic IntegerField/BooleanField per
metric in __init__. save() serializes both halves into the
JSON blob. clean() validates min ≤ max temperature.
Views:
- site_report_edit — create-or-update; stamps created_by on
first save; preserves it on subsequent admin edits
- site_report_detail — read-only display; 404 when no report
- attendance_log redirect updated to two-step flow
- _can_access_site_report — shared permission helper
URLs:
- /site-report/<work_log_id>/edit/ (name: site_report_edit)
- /site-report/<work_log_id>/ (name: site_report_detail)
Templates:
- site_report_edit.html — mobile-first stack of inputs, weather
as a chunky icon-button row (☀️ ☁️ 🌧️ ⛈️ 🥵 🥶 💨), counts in a
2-col grid, checks as toggle switches, Notes textarea, Skip
+ Save buttons. Iterates pre-built (metric, bound_field)
pairs from the view to avoid needing a new template filter.
- site_report_detail.html — counts as accent-coloured value
cards, checks as a check-list, weather + temp + notes + edit
link.
- work_history.html — added a small clipboard icon next to
each row's date: filled (linked to detail) when a report
exists, muted outline (linked to edit) when not. Click is
event.stopPropagation()-ed so the row's payroll-modal
handler doesn't also fire.
Performance:
- work_history queryset adds .select_related('site_report') so
the new template indicator doesn't introduce an N+1.
Admin:
- SiteReport registered with raw_id_fields on work_log +
created_by, list filters on weather + project + date.
Tests (16 new, full suite 85/85):
- SiteReportModelTests — defaults, 1:1 reverse accessor,
arbitrary-key JSON round-trip
- SiteReportFormTests — dynamic field generation, save
serialisation, temp validation, instance pre-fill
- SiteReportEditViewTests — admin GET/POST, project
supervisor allowed, outsider supervisor 403, created_by
preserved on subsequent admin edits
- SiteReportDetailViewTests — 404 when absent, displays data
when present
- AttendanceLogRedirectsToSiteReportTests — confirms the
two-step flow
CLAUDE.md updates:
- SiteReport added to "Key Models" with shape + reverse-accessor note
- New "SiteReport metric schema" section near "UI-vs-DB
naming drift" — explains the JSON-column-with-Python-source
pattern, when it's safe, what NOT to do (rename a key with
data), and where the keys appear across the codebase
- URL Routes table gets the two new endpoints
What's NOT in this commit (deferred per the brainstorm plan):
- JournalEntry model + manual web-entry UI (Phase A.2 — depends
on Konrad's Q7 answer about Vi/recipient field)
- Letterly inbound webhook (Phase B — integrations branch only,
depends on Q5 sample payload)
- Photos on site reports (Q9, defaulted to "future")
- Per-project metric templates (Q4, defaulted to "same set for all v1")
Reference plan: ~/.claude/plans/prancy-painting-brook.md (local).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3da039b74e
commit
864ae722c4
43
CLAUDE.md
43
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/<work_log_id>/edit/` | `site_report_edit` | Create-or-update the optional SiteReport for a WorkLog (auto-redirected here after `/attendance/log/` POST) |
|
||||
| `/site-report/<work_log_id>/` | `site_report_detail` | Read-only view of the SiteReport (404 if none — use the edit URL to create) |
|
||||
| `/workers/export/` | `export_workers_csv` | Admin: export all workers to CSV |
|
||||
| `/workers/` | `worker_list` | Admin: friendly worker list with search + status filter |
|
||||
| `/workers/new/` | `worker_edit` | Admin: blank worker-create form |
|
||||
|
||||
@ -4,6 +4,7 @@ from .models import (
|
||||
PayrollRecord, Loan, PayrollAdjustment,
|
||||
ExpenseReceipt, ExpenseLineItem,
|
||||
WorkerCertificate, WorkerWarning,
|
||||
SiteReport,
|
||||
)
|
||||
|
||||
@admin.register(UserProfile)
|
||||
@ -112,6 +113,21 @@ class LoanAdmin(admin.ModelAdmin):
|
||||
list_filter = ('active', 'date', 'worker')
|
||||
search_fields = ('worker__name', 'reason')
|
||||
|
||||
@admin.register(SiteReport)
|
||||
class SiteReportAdmin(admin.ModelAdmin):
|
||||
"""Admin view for daily site progress reports.
|
||||
|
||||
`metrics` is a JSONField — Django renders it as a textarea blob,
|
||||
which is fine for low-frequency admin edits. The friendly
|
||||
/site-report/<id>/edit/ form is the primary entry point.
|
||||
"""
|
||||
list_display = ('work_log', 'weather', 'temperature_min', 'temperature_max', 'created_by', 'created_at')
|
||||
list_filter = ('weather', 'created_at', 'work_log__project')
|
||||
search_fields = ('notes', 'work_log__project__name', 'work_log__supervisor__username')
|
||||
raw_id_fields = ('work_log', 'created_by')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
|
||||
|
||||
@admin.register(PayrollAdjustment)
|
||||
class PayrollAdjustmentAdmin(admin.ModelAdmin):
|
||||
list_display = ('worker', 'type_display', 'amount', 'date')
|
||||
|
||||
147
core/forms.py
147
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
|
||||
|
||||
34
core/migrations/0013_add_site_report.py
Normal file
34
core/migrations/0013_add_site_report.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-27 00:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_alter_payrolladjustment_type_display_labels'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SiteReport',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('weather', models.CharField(blank=True, choices=[('', '—'), ('sunny', 'Sunny'), ('cloudy', 'Cloudy / Overcast'), ('rain', 'Rain'), ('storm', 'Storm'), ('hot', 'Hot'), ('cold', 'Cold'), ('windy', 'Windy')], help_text='Dominant weather of the working day.', max_length=20)),
|
||||
('temperature_min', models.IntegerField(blank=True, help_text='Coldest temperature seen during the working day, °C.', null=True)),
|
||||
('temperature_max', models.IntegerField(blank=True, help_text='Hottest temperature seen during the working day, °C.', null=True)),
|
||||
('notes', models.TextField(blank=True, help_text='Free-form prose. What happened, what was tricky, who came on site, etc.')),
|
||||
('metrics', models.JSONField(blank=True, default=dict, help_text='Per-day operational metrics. Schema in core/site_report_schema.py.')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='site_reports_created', to=settings.AUTH_USER_MODEL)),
|
||||
('work_log', models.OneToOneField(help_text='The WorkLog this site report belongs to (1:1).', on_delete=django.db.models.deletion.CASCADE, related_name='site_report', to='core.worklog')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-work_log__date', '-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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")
|
||||
|
||||
96
core/site_report_schema.py
Normal file
96
core/site_report_schema.py
Normal file
@ -0,0 +1,96 @@
|
||||
# === core/site_report_schema.py ===
|
||||
# Single source of truth for the on-site progress metric set used by
|
||||
# SiteReport.metrics (a JSONField). Edit this file to add/remove/rename
|
||||
# metrics — NO database migration required, the JSONField stores
|
||||
# whatever keys you save.
|
||||
#
|
||||
# Why this file exists:
|
||||
# The SiteReport model has structured fields (weather, temperature,
|
||||
# notes, supervisor, timestamps) AND a JSON `metrics` blob. The blob
|
||||
# stores per-day operational counts and checkboxes (e.g. how many
|
||||
# plinths were cast today, was steel tied, did the team go to town
|
||||
# for materials). These metrics will EVOLVE over time as different
|
||||
# construction phases come up — and adding a new column for every
|
||||
# change would mean a migration, a deploy, and a risk of breaking
|
||||
# historic data.
|
||||
#
|
||||
# How to evolve:
|
||||
# - Add a new metric: append a dict to COUNT_METRICS or CHECK_METRICS
|
||||
# with a unique snake_case `key` and a human `label`. Done. The
|
||||
# form renders it on next page load. Old reports just don't have
|
||||
# that key in their JSON — which is fine, they render as 0 / unchecked.
|
||||
# - Rename a label: change the `label` value only — the `key` stays
|
||||
# so old data still maps to the same metric.
|
||||
# - Retire a metric: delete it from this list. Old reports keep their
|
||||
# data in the JSON (it's just no longer rendered in the form). If
|
||||
# you want to fully purge it from the DB, write a one-off management
|
||||
# command — the JSON is just a Python dict at the storage layer.
|
||||
#
|
||||
# How NOT to use this file:
|
||||
# - Do NOT change a `key` of a metric that has data — historic reports
|
||||
# will silently lose their value for that metric. If a key really
|
||||
# must change, write a data migration that walks every SiteReport
|
||||
# and renames the JSON key.
|
||||
|
||||
# === COUNT METRICS ===
|
||||
# Numeric counts. Form renders each as a `<input type="number" min="0">`
|
||||
# with a numeric keyboard on mobile. Default value is 0 (or blank) —
|
||||
# blank is treated the same as 0 when summing for reports.
|
||||
COUNT_METRICS = [
|
||||
{'key': 'plinth_holes_dug', 'label': 'Plinth holes dug'},
|
||||
{'key': 'plinths_cast', 'label': 'Plinths cast'},
|
||||
{'key': 'plinths_deshuttered', 'label': 'Plinths de-shuttered'},
|
||||
{'key': 'tables_set_out', 'label': 'Tables set out'},
|
||||
{'key': 'boxes_placed', 'label': 'Boxes placed'},
|
||||
{'key': 'shutter_boxes_leveled', 'label': 'Shutter boxes leveled'},
|
||||
{'key': 'steel_placed', 'label': 'Steel placed'},
|
||||
]
|
||||
|
||||
# === CHECK METRICS ===
|
||||
# Boolean flags. Form renders each as a large-tap-target checkbox.
|
||||
# Default value is False (unchecked) — absence in the JSON means False.
|
||||
CHECK_METRICS = [
|
||||
{'key': 'steel_tied', 'label': 'Steel tied / built'},
|
||||
{'key': 'boxes_cleaned', 'label': 'Boxes cleaned'},
|
||||
{'key': 'town_run', 'label': 'Town for materials / food'},
|
||||
{'key': 'rain_delay', 'label': 'Rain delay'},
|
||||
]
|
||||
|
||||
|
||||
def get_count_keys():
|
||||
"""Return the list of count-metric keys in display order.
|
||||
|
||||
Useful for: rendering the form, validating posted data, summing
|
||||
counts across many reports for a project dashboard.
|
||||
"""
|
||||
return [m['key'] for m in COUNT_METRICS]
|
||||
|
||||
|
||||
def get_check_keys():
|
||||
"""Return the list of check-metric keys in display order."""
|
||||
return [m['key'] for m in CHECK_METRICS]
|
||||
|
||||
|
||||
def label_for(key):
|
||||
"""Look up the human label for a metric key.
|
||||
|
||||
Returns the key itself (snake_case) if no match — useful as a
|
||||
fallback when displaying historic data whose key has since been
|
||||
retired from the schema.
|
||||
"""
|
||||
for m in COUNT_METRICS + CHECK_METRICS:
|
||||
if m['key'] == key:
|
||||
return m['label']
|
||||
return key
|
||||
|
||||
|
||||
def empty_metrics():
|
||||
"""Return a fresh metrics dict with all current keys at default values.
|
||||
|
||||
Counts default to 0, checks default to False. Use as a default for
|
||||
new SiteReport instances OR as a template when comparing reports.
|
||||
"""
|
||||
return {
|
||||
'counts': {m['key']: 0 for m in COUNT_METRICS},
|
||||
'checks': {m['key']: False for m in CHECK_METRICS},
|
||||
}
|
||||
140
core/templates/core/site_report_detail.html
Normal file
140
core/templates/core/site_report_detail.html
Normal file
@ -0,0 +1,140 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Site Report — {{ work_log.date|date:"d M Y" }} | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% comment %}
|
||||
Read-only display of a SiteReport. Reachable from:
|
||||
- The work-history page indicator (📋 icon next to a log)
|
||||
- Direct URL: /site-report/<work_log_id>/
|
||||
Same permission scope as the edit view: admin OR supervisor of the
|
||||
parent work log / project.
|
||||
|
||||
Designed to read well on a phone (column-stacked sections, compact
|
||||
metric tables) AND on a desktop where someone reviewing weekly progress
|
||||
might want to scan multiple at once.
|
||||
{% endcomment %}
|
||||
|
||||
<div class="container py-4">
|
||||
|
||||
{# === Page header === #}
|
||||
<div class="d-flex justify-content-between align-items-start gap-2 mb-3">
|
||||
<div>
|
||||
<h1 class="page-title mb-0">
|
||||
<i class="fas fa-clipboard-check me-2" style="color: var(--accent);"></i>
|
||||
Site Report
|
||||
</h1>
|
||||
<small class="text-muted">
|
||||
{{ work_log.project.name }} —
|
||||
{{ work_log.date|date:"l, d M Y" }}
|
||||
{% if work_log.team %}({{ work_log.team.name }}){% endif %}
|
||||
</small>
|
||||
{% if site_report.created_by %}
|
||||
<div class="text-muted" style="font-size: 0.8rem;">
|
||||
Logged by {{ site_report.created_by.get_full_name|default:site_report.created_by.username }}
|
||||
· {{ site_report.created_at|date:"d M Y, H:i" }}
|
||||
{% if site_report.updated_at and site_report.updated_at != site_report.created_at %}
|
||||
· last edited {{ site_report.updated_at|date:"d M Y, H:i" }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'site_report_edit' work_log.id %}" class="btn btn-outline-secondary btn-sm" title="Edit this report">
|
||||
<i class="fas fa-pen fa-sm me-1"></i> Edit
|
||||
</a>
|
||||
<a href="{% url 'work_history' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left fa-sm me-1"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-3 p-md-4">
|
||||
|
||||
{# === Conditions === #}
|
||||
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
|
||||
Conditions
|
||||
</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<strong>Weather:</strong>
|
||||
{% if site_report.weather %}
|
||||
{% if site_report.weather == 'sunny' %}☀️
|
||||
{% elif site_report.weather == 'cloudy' %}☁️
|
||||
{% elif site_report.weather == 'rain' %}🌧️
|
||||
{% elif site_report.weather == 'storm' %}⛈️
|
||||
{% elif site_report.weather == 'hot' %}🥵
|
||||
{% elif site_report.weather == 'cold' %}🥶
|
||||
{% elif site_report.weather == 'windy' %}💨
|
||||
{% endif %}
|
||||
{{ site_report.get_weather_display }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<strong>Temperature:</strong>
|
||||
{% if site_report.temperature_min is not None or site_report.temperature_max is not None %}
|
||||
{% if site_report.temperature_min is not None %}{{ site_report.temperature_min }}°C{% else %}—{% endif %}
|
||||
/
|
||||
{% if site_report.temperature_max is not None %}{{ site_report.temperature_max }}°C{% else %}—{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">not recorded</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === Counts === #}
|
||||
{% if counts_display %}
|
||||
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
|
||||
Counts
|
||||
</h6>
|
||||
<div class="row g-2 mb-4">
|
||||
{% for c in counts_display %}
|
||||
<div class="col-6 col-md-4">
|
||||
<div class="card" style="background: var(--bg-card-hover); border: 1px solid rgba(255,255,255,0.05);">
|
||||
<div class="card-body p-2 text-center">
|
||||
<div style="font-size: 1.5rem; font-weight: 700; color: var(--accent);">
|
||||
{{ c.value }}
|
||||
</div>
|
||||
<div class="text-muted" style="font-size: 0.75rem;">{{ c.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === Checks === #}
|
||||
{% if checks_display %}
|
||||
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
|
||||
Other
|
||||
</h6>
|
||||
<ul class="list-unstyled mb-4">
|
||||
{% for c in checks_display %}
|
||||
<li class="mb-1">
|
||||
{% if c.value %}
|
||||
<i class="fas fa-check-circle me-1" style="color: var(--badge-bonus-bg);"></i>
|
||||
{% else %}
|
||||
<i class="far fa-circle me-1 text-muted"></i>
|
||||
{% endif %}
|
||||
<span {% if not c.value %}class="text-muted"{% endif %}>{{ c.label }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{# === Free-form notes === #}
|
||||
{% if site_report.notes %}
|
||||
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
|
||||
Notes
|
||||
</h6>
|
||||
<div class="mb-2" style="white-space: pre-wrap;">{{ site_report.notes }}</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
181
core/templates/core/site_report_edit.html
Normal file
181
core/templates/core/site_report_edit.html
Normal file
@ -0,0 +1,181 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Site Report — {{ work_log.date|date:"d M Y" }} | FoxFitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% comment %}
|
||||
Mobile-first site-report edit form. Reachable two ways:
|
||||
1. Auto-redirect after a successful /attendance/log/ submission (the
|
||||
"two-step flow" — supervisor logs WHO worked, then lands here to
|
||||
log WHAT was done).
|
||||
2. Direct visit /site-report/<work_log_id>/edit/ (admin or supervisor
|
||||
of the parent work log) — useful for backfilling a missed report.
|
||||
|
||||
The form is deliberately optional: a "Skip" link goes straight home so
|
||||
supervisors who're in a hurry can ship the attendance log without the
|
||||
progress data. WorkLogs without a SiteReport are completely valid.
|
||||
|
||||
Layout principles:
|
||||
- Vertical stack of inputs (no multi-column on phone)
|
||||
- Large tap-targets for the boolean checkboxes
|
||||
- inputmode="numeric" on count + temperature fields → numeric keypad
|
||||
- Weather rendered as a chunky icon-button row, not a dropdown
|
||||
{% endcomment %}
|
||||
|
||||
<div class="container py-4">
|
||||
|
||||
{# === Page header === #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="page-title mb-0">
|
||||
<i class="fas fa-clipboard-check me-2" style="color: var(--accent);"></i>
|
||||
{% if is_creating %}Log Today's Work{% else %}Edit Site Report{% endif %}
|
||||
</h1>
|
||||
<small class="text-muted">
|
||||
{{ work_log.project.name }} —
|
||||
{{ work_log.date|date:"l, d M Y" }}
|
||||
{% if work_log.team %}({{ work_log.team.name }}){% endif %}
|
||||
</small>
|
||||
</div>
|
||||
{# Skip = abandon this report (intentional) and go home. #}
|
||||
{# Save the attendance, ship the day. #}
|
||||
<a href="{% url 'home' %}"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
title="Skip the progress report — attendance is already saved">
|
||||
<i class="fas fa-forward fa-sm me-1"></i> Skip
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# === Helpful note for the supervisor — only visible on first creation === #}
|
||||
{% if is_creating %}
|
||||
<div class="alert alert-info py-2 mb-3" style="font-size: 0.875rem;">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Logging the day's progress is optional. Skip if you're in a hurry —
|
||||
the attendance entry is already saved.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === The form === #}
|
||||
<div class="card">
|
||||
<div class="card-body p-3 p-md-4">
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
{# --- Form-level error display --- #}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger py-2">
|
||||
{% for error in form.non_field_errors %}<div>{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === SECTION 1 — Weather + Temperature === #}
|
||||
<div class="mb-4">
|
||||
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
|
||||
Conditions
|
||||
</h6>
|
||||
|
||||
{# --- Weather: icon-button row, picks one --- #}
|
||||
{# Bootstrap btn-check + label pattern gives us large tap targets #}
|
||||
{# without a custom JavaScript handler. #}
|
||||
<div class="mb-3">
|
||||
<label class="form-label mb-1 fw-semibold">Weather</label>
|
||||
<div class="btn-group flex-wrap" role="group" aria-label="Weather">
|
||||
{% for choice_value, choice_label in form.weather.field.choices %}
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="weather"
|
||||
id="weather-{{ choice_value|default:'none' }}"
|
||||
value="{{ choice_value }}"
|
||||
{% if form.weather.value == choice_value %}checked{% endif %}
|
||||
autocomplete="off">
|
||||
<label class="btn btn-outline-secondary btn-sm me-1 mb-1" for="weather-{{ choice_value|default:'none' }}">
|
||||
{% if choice_value == 'sunny' %}☀️ Sunny
|
||||
{% elif choice_value == 'cloudy' %}☁️ Cloudy
|
||||
{% elif choice_value == 'rain' %}🌧️ Rain
|
||||
{% elif choice_value == 'storm' %}⛈️ Storm
|
||||
{% elif choice_value == 'hot' %}🥵 Hot
|
||||
{% elif choice_value == 'cold' %}🥶 Cold
|
||||
{% elif choice_value == 'windy' %}💨 Windy
|
||||
{% else %}—{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.weather.errors %}<div class="text-danger small mt-1">{{ form.weather.errors|first }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
{# --- Temperature min/max: two short numeric fields --- #}
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label for="{{ form.temperature_min.id_for_label }}" class="form-label fw-semibold">Min °C</label>
|
||||
{{ form.temperature_min }}
|
||||
{% if form.temperature_min.errors %}<div class="text-danger small mt-1">{{ form.temperature_min.errors|first }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label for="{{ form.temperature_max.id_for_label }}" class="form-label fw-semibold">Max °C</label>
|
||||
{{ form.temperature_max }}
|
||||
{% if form.temperature_max.errors %}<div class="text-danger small mt-1">{{ form.temperature_max.errors|first }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === SECTION 2 — Count metrics === #}
|
||||
{# Iterates (metric, bound_field) tuples pre-built in the view #}
|
||||
{# so we don't need a "form-by-variable-name" template filter. #}
|
||||
<div class="mb-4">
|
||||
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
|
||||
Counts (today's totals)
|
||||
</h6>
|
||||
<div class="row g-2">
|
||||
{% for metric, bound_field in count_field_pairs %}
|
||||
<div class="col-6 col-md-4">
|
||||
<label for="{{ bound_field.id_for_label }}" class="form-label fw-semibold mb-1" style="font-size: 0.875rem;">
|
||||
{{ metric.label }}
|
||||
</label>
|
||||
{{ bound_field }}
|
||||
{% if bound_field.errors %}<div class="text-danger small mt-1">{{ bound_field.errors|first }}</div>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === SECTION 3 — Check metrics (booleans) === #}
|
||||
<div class="mb-4">
|
||||
<h6 class="text-uppercase mb-2" style="font-size: 0.75rem; letter-spacing: 0.05em; color: var(--text-secondary);">
|
||||
Other (tap to mark)
|
||||
</h6>
|
||||
{% for metric, bound_field in check_field_pairs %}
|
||||
<div class="form-check form-switch mb-2" style="padding-left: 2.75em;">
|
||||
{{ bound_field }}
|
||||
<label class="form-check-label fw-semibold" for="{{ bound_field.id_for_label }}" style="font-size: 0.95rem;">
|
||||
{{ metric.label }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# === SECTION 4 — Free-form notes === #}
|
||||
<div class="mb-4">
|
||||
<label for="{{ form.notes.id_for_label }}" class="form-label fw-semibold">
|
||||
Notes
|
||||
<small class="text-muted fw-normal">(optional)</small>
|
||||
</label>
|
||||
{{ form.notes }}
|
||||
{% if form.notes.errors %}<div class="text-danger small mt-1">{{ form.notes.errors|first }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
{# === Actions === #}
|
||||
<div class="d-flex justify-content-between gap-2">
|
||||
<a href="{% url 'home' %}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times me-1"></i> Skip
|
||||
</a>
|
||||
<button type="submit" class="btn btn-accent">
|
||||
<i class="fas fa-check me-1"></i>
|
||||
{% if is_creating %}Save Site Report{% else %}Update{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -440,7 +440,30 @@
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr {% if is_admin %}class="work-log-row" data-log-id="{{ log.id }}" style="cursor: pointer;"{% endif %}>
|
||||
<td class="ps-4 align-middle">{{ log.date }}</td>
|
||||
<td class="ps-4 align-middle">
|
||||
{{ log.date }}
|
||||
{# Site-report indicator: clipboard icon links to the read-only #}
|
||||
{# detail page if a report exists; muted "+ Add" link otherwise. #}
|
||||
{# Click is event.stopPropagation()-ed so the row's payroll-modal #}
|
||||
{# click handler doesn't also fire when supervisors click here. #}
|
||||
{% if log.site_report %}
|
||||
<a href="{% url 'site_report_detail' log.id %}"
|
||||
onclick="event.stopPropagation()"
|
||||
class="ms-1"
|
||||
title="View site report"
|
||||
style="color: var(--accent); text-decoration: none;">
|
||||
<i class="fas fa-clipboard-check fa-sm"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'site_report_edit' log.id %}"
|
||||
onclick="event.stopPropagation()"
|
||||
class="ms-1"
|
||||
title="Add site report"
|
||||
style="color: var(--text-tertiary); text-decoration: none; opacity: 0.6;">
|
||||
<i class="far fa-clipboard fa-sm"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle"><strong>{{ log.project.name }}</strong></td>
|
||||
<td class="align-middle">
|
||||
{% if filtered_worker_obj %}
|
||||
|
||||
305
core/tests.py
305
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)
|
||||
|
||||
@ -25,6 +25,13 @@ urlpatterns = [
|
||||
path('history/<int:log_id>/', views.work_log_payroll_detail, name='work_log_payroll_detail'),
|
||||
path('history/<int:log_id>/payroll/ajax/', views.work_log_payroll_ajax, name='work_log_payroll_ajax'),
|
||||
|
||||
# === SITE REPORT (DAILY PROGRESS) ===
|
||||
# Companion to attendance: log WHAT was done on site (counts, checks,
|
||||
# weather, free-form notes). Edit page is the redirect target after
|
||||
# /attendance/log/ submission. Detail page is read-only.
|
||||
path('site-report/<int:work_log_id>/edit/', views.site_report_edit, name='site_report_edit'),
|
||||
path('site-report/<int:work_log_id>/', views.site_report_detail, name='site_report_detail'),
|
||||
|
||||
# CSV export — downloads all worker data (admin only)
|
||||
path('workers/export/', views.export_workers_csv, name='export_workers_csv'),
|
||||
|
||||
|
||||
172
core/views.py
172
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/<work_log_id>/edit/
|
||||
Permission: admin, or the WorkLog's supervisor / project supervisor.
|
||||
Behaviour:
|
||||
- GET on a WorkLog WITHOUT an existing report → blank form
|
||||
- GET on a WorkLog WITH a report → form pre-filled with current values
|
||||
- POST → save, flash a toast, redirect home
|
||||
The form has a "Skip" link in the template that goes straight home.
|
||||
"""
|
||||
work_log = get_object_or_404(
|
||||
WorkLog.objects.select_related('project', 'team', 'supervisor'),
|
||||
id=work_log_id,
|
||||
)
|
||||
|
||||
if not _can_access_site_report(request.user, work_log):
|
||||
return HttpResponseForbidden(
|
||||
"You don't have permission to edit this site report."
|
||||
)
|
||||
|
||||
# Pull the existing report if there is one. The 1:1 reverse accessor
|
||||
# raises DoesNotExist when absent, so we wrap with a try/except (a
|
||||
# bit cleaner than hasattr() given the field name).
|
||||
try:
|
||||
site_report = work_log.site_report
|
||||
except SiteReport.DoesNotExist:
|
||||
site_report = None
|
||||
|
||||
if request.method == 'POST':
|
||||
form = SiteReportForm(
|
||||
request.POST,
|
||||
instance=site_report, # None = create new
|
||||
work_log=work_log, # only used when creating
|
||||
)
|
||||
if form.is_valid():
|
||||
instance = form.save(commit=False)
|
||||
# Stamp `created_by` only on first save — keep the original
|
||||
# author even if an admin edits it later. updated_at is
|
||||
# handled automatically by auto_now=True on the model.
|
||||
if instance.pk is None:
|
||||
instance.created_by = request.user
|
||||
instance.save()
|
||||
messages.success(
|
||||
request,
|
||||
f"Site report saved for {work_log.project.name} on {work_log.date:%d %b %Y}.",
|
||||
)
|
||||
return redirect('home')
|
||||
# Form validation errors fall through to render(...) below
|
||||
else:
|
||||
form = SiteReportForm(instance=site_report, work_log=work_log)
|
||||
|
||||
# Build (metric_dict, bound_field) pairs for the template. The form's
|
||||
# dynamic fields are named "count_<key>" / "check_<key>" — the
|
||||
# template iterates these pairs rather than calling form[name] with
|
||||
# a variable key (which Django templates can't do without a custom
|
||||
# filter). Pre-building here keeps the template clean.
|
||||
count_field_pairs = [
|
||||
(m, form[f"count_{m['key']}"]) for m in COUNT_METRICS
|
||||
]
|
||||
check_field_pairs = [
|
||||
(m, form[f"check_{m['key']}"]) for m in CHECK_METRICS
|
||||
]
|
||||
|
||||
return render(request, 'core/site_report_edit.html', {
|
||||
'form': form,
|
||||
'work_log': work_log,
|
||||
'site_report': site_report,
|
||||
'count_field_pairs': count_field_pairs,
|
||||
'check_field_pairs': check_field_pairs,
|
||||
'is_creating': site_report is None,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def site_report_detail(request, work_log_id):
|
||||
"""Read-only view of a SiteReport.
|
||||
|
||||
URL: /site-report/<work_log_id>/
|
||||
Permission: same scope as site_report_edit.
|
||||
Behaviour:
|
||||
- 404 if there's no report for this work log (use the edit URL
|
||||
to create one — the link in templates points to the right URL
|
||||
based on whether a report exists).
|
||||
"""
|
||||
work_log = get_object_or_404(
|
||||
WorkLog.objects.select_related('project', 'team', 'supervisor'),
|
||||
id=work_log_id,
|
||||
)
|
||||
|
||||
if not _can_access_site_report(request.user, work_log):
|
||||
return HttpResponseForbidden(
|
||||
"You don't have permission to view this site report."
|
||||
)
|
||||
|
||||
site_report = get_object_or_404(SiteReport, work_log=work_log)
|
||||
|
||||
# Build a flat list of (label, value) pairs for the template — this
|
||||
# is the easiest way to render historic JSON keys whose label might
|
||||
# have been retired from the schema (label_for falls back to the key).
|
||||
counts_display = []
|
||||
for key, value in (site_report.metrics.get('counts', {}) or {}).items():
|
||||
counts_display.append({'key': key, 'label': label_for(key), 'value': value})
|
||||
|
||||
checks_display = []
|
||||
for key, value in (site_report.metrics.get('checks', {}) or {}).items():
|
||||
checks_display.append({'key': key, 'label': label_for(key), 'value': bool(value)})
|
||||
|
||||
return render(request, 'core/site_report_detail.html', {
|
||||
'work_log': work_log,
|
||||
'site_report': site_report,
|
||||
'counts_display': counts_display,
|
||||
'checks_display': checks_display,
|
||||
})
|
||||
|
||||
|
||||
# === WORK LOG HISTORY ===
|
||||
# Shows work logs in two modes: a table list or a monthly calendar grid.
|
||||
# Supervisors only see their own projects. Admins see everything.
|
||||
@ -666,11 +830,15 @@ def work_history(request):
|
||||
user = request.user
|
||||
|
||||
# Start with base queryset
|
||||
# NOTE: select_related('site_report') prevents an N+1 query when
|
||||
# the template checks `log.site_report` for the indicator icon.
|
||||
# The 1:1 reverse relation is lazy by default — without this hint,
|
||||
# each row would issue a separate SELECT.
|
||||
if is_admin(user):
|
||||
logs = WorkLog.objects.all()
|
||||
logs = WorkLog.objects.select_related('site_report')
|
||||
else:
|
||||
# Supervisors only see logs for their projects
|
||||
logs = WorkLog.objects.filter(
|
||||
logs = WorkLog.objects.select_related('site_report').filter(
|
||||
Q(supervisor=user) | Q(project__supervisors=user)
|
||||
).distinct()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user