38686-vm/core/admin.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

160 lines
6.3 KiB
Python

from django.contrib import admin
from .models import (
UserProfile, Project, Worker, Team, WorkLog,
PayrollRecord, Loan, PayrollAdjustment,
ExpenseReceipt, ExpenseLineItem,
WorkerCertificate, WorkerWarning,
SiteReport,
)
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('user',)
search_fields = ('user__username', 'user__first_name', 'user__last_name')
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ('name', 'active')
list_filter = ('active',)
search_fields = ('name', 'description')
filter_horizontal = ('supervisors',)
# === INLINE ADMINS FOR WORKER ===
# Let admins manage a worker's certifications and warnings directly
# from the Worker change page, without navigating to a separate screen.
class WorkerCertificateInline(admin.TabularInline):
model = WorkerCertificate
extra = 0 # no blank rows by default — admin clicks "Add another" to create
readonly_fields = ('created_at',)
fields = ('cert_type', 'document', 'issued_date', 'valid_until', 'notes', 'created_at')
class WorkerWarningInline(admin.TabularInline):
model = WorkerWarning
extra = 0
readonly_fields = ('created_at',)
fields = ('date', 'severity', 'reason', 'description', 'issued_by', 'document', 'created_at')
@admin.register(Worker)
class WorkerAdmin(admin.ModelAdmin):
list_display = ('name', 'id_number', 'monthly_salary', 'active')
list_filter = ('active', 'has_drivers_license')
search_fields = ('name', 'id_number', 'phone_number')
# Inline sections for certs + warnings appear below the main Worker form
inlines = [WorkerCertificateInline, WorkerWarningInline]
# === FIELDSETS ===
# Organise the worker edit form into clear sections.
# Banking & Tax fields (UIF, Bank, Acc No.) live inside Personal Info
# per product requirement — help_text strings render as hints under
# each field in admin (and as tooltips on the friendly edit page).
fieldsets = (
('Personal Info', {
'fields': ('name', 'id_number', 'phone_number', 'monthly_salary',
'tax_number', 'uif_number',
'bank_name', 'bank_account_number',
'employment_date', 'active', 'notes'),
}),
('Sizing', {
'fields': ('shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size'),
}),
('Documents & License', {
'fields': ('photo', 'id_document',
'has_drivers_license', 'drivers_license', 'drivers_license_code'),
}),
)
# === STANDALONE ADMINS FOR CERTS + WARNINGS ===
# Separate pages for bulk operations across workers — "show me all
# certs expiring this month" or "show me all final warnings".
@admin.register(WorkerCertificate)
class WorkerCertificateAdmin(admin.ModelAdmin):
list_display = ('worker', 'cert_type', 'issued_date', 'valid_until', 'is_expired')
list_filter = ('cert_type',)
search_fields = ('worker__name', 'worker__id_number')
date_hierarchy = 'valid_until'
@admin.register(WorkerWarning)
class WorkerWarningAdmin(admin.ModelAdmin):
list_display = ('worker', 'date', 'severity', 'reason', 'issued_by')
list_filter = ('severity',)
search_fields = ('worker__name', 'reason', 'description')
date_hierarchy = 'date'
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'supervisor', 'pay_frequency', 'pay_start_date', 'active')
list_editable = ('pay_frequency', 'pay_start_date')
list_filter = ('active', 'supervisor', 'pay_frequency')
search_fields = ('name',)
filter_horizontal = ('workers',)
@admin.register(WorkLog)
class WorkLogAdmin(admin.ModelAdmin):
list_display = ('date', 'project', 'supervisor', 'overtime_amount')
list_filter = ('date', 'project', 'supervisor')
search_fields = ('project__name', 'notes')
filter_horizontal = ('workers', 'priced_workers')
@admin.register(PayrollRecord)
class PayrollRecordAdmin(admin.ModelAdmin):
list_display = ('worker', 'date', 'amount_paid')
list_filter = ('date', 'worker')
search_fields = ('worker__name',)
filter_horizontal = ('work_logs',)
@admin.register(Loan)
class LoanAdmin(admin.ModelAdmin):
list_display = ('worker', 'principal_amount', 'remaining_balance', 'date', 'active')
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')
list_filter = ('type', 'date', 'worker')
search_fields = ('worker__name', 'description')
# === Type column uses the short user-facing label ===
@admin.display(description='Type', ordering='type')
def type_display(self, obj):
"""Show the short user-facing label (e.g. "Loan", "Advance")
instead of the raw DB value ("New Loan", "Advance Payment").
Sorting and filtering still work off the underlying `type`
field — this only changes what's printed in the column."""
return obj.get_type_display()
class ExpenseLineItemInline(admin.TabularInline):
model = ExpenseLineItem
extra = 1
@admin.register(ExpenseReceipt)
class ExpenseReceiptAdmin(admin.ModelAdmin):
list_display = ('vendor_name', 'date', 'total_amount', 'user')
list_filter = ('date', 'payment_method', 'vat_type')
search_fields = ('vendor_name', 'description')
inlines = [ExpenseLineItemInline]
@admin.register(ExpenseLineItem)
class ExpenseLineItemAdmin(admin.ModelAdmin):
list_display = ('product_name', 'amount', 'receipt')
search_fields = ('product_name', 'receipt__vendor_name')