Complete working state of the session. Will be split into two deploy phases (safety scaffolding then feature release) before merging to ai-dev. Includes: - Security fixes (email creds / SECRET_KEY / DEBUG / CSRF) - Backup + restore management commands and browser endpoints - WeasyPrint migration (replaces xhtml2pdf) - New Worker fields + WorkerCertificate + WorkerWarning models - Worker / Team / Project friendly management UIs - Dashboard cert-expiry card + Manage All buttons - Bootstrap tooltips (global init + theme-aware CSS) - Django admin template override (taller M2M pickers) - Money filter for ZAR currency formatting - Resources dropdown nav - Massive CLAUDE.md expansion + deploy plan docs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24 KiB
Worker Management Expansion — Design & Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. REQUIRED SUB-SKILL: Use superpowers:test-driven-development where tests are called for.
Goal: Extend the Worker model with certifications + disciplinary records, add a friendly in-app worker-edit form (replacing the need to use Django admin for common edits), and build a batch worker report showing projects, teams, days worked, and first/last payslip dates.
Architecture:
- Two new models (
WorkerCertificate,WorkerWarning) with ForeignKey to existingWorker. - A worker list page + worker edit page, both admin-only, replacing most everyday admin needs.
- A batch-report page (HTML + CSV + PDF) summarising each worker's full work history.
- All new work lives inside the existing
core/app — no new apps. Reuses existing patterns:is_admin()gating,@login_required, Bootstrap modals,render_to_pdf(), inline formsets.
Tech Stack: Django 5.2.7, Python 3.13, SQLite (local) / MySQL (prod), Bootstrap 5, WeasyPrint for PDFs, existing format_tags.money filter.
Context
Why this is being built:
The owner (Konrad) currently manages workers via /admin/core/worker/ — which is functional but lacks:
- Certification tracking (Skills, PDP, First Aid, Medical, Work at Height) with expiry dates and document uploads
- A disciplinary/warnings record per worker
- A friendlier edit UI than Django admin's default form
- A consolidated worker-history report (projects worked, days, payslips) for review or regulatory/auditor purposes
This plan closes all four gaps in one coherent feature.
Outcomes when complete:
- Per worker, the admin can see and maintain: all 5 cert types, all warnings, in one page.
- The admin has a worker list and worker edit page that are easier than Django admin.
- The admin can generate a batch report of all workers' project/team/day/payslip history — viewable, CSV-exportable, PDF-exportable.
Scope (explicit)
In scope
- New models:
WorkerCertificate,WorkerWarning - Django admin registrations for both (inline on Worker)
- Worker list page (admin-only):
/workers/ - Worker edit page (admin-only):
/workers/<id>/edit/and/workers/new/ - Worker detail page (admin-only, read-only view with history):
/workers/<id>/ - Batch worker report:
/workers/report/(HTML),/workers/report/csv/,/workers/report/pdf/ - Nav links added to base.html
- Migrations for the two new models
- Updates to
CLAUDE.mddocumenting the new pieces
Out of scope (not in this plan — can be follow-ups)
- Cert expiry email alerts
- Worker self-service portal (only admins use this)
- Photo cropping / file optimisation
- Bulk cert upload (edit one worker at a time)
Files to Create / Modify
| File | Action | Purpose |
|---|---|---|
core/models.py |
Modify | Add WorkerCertificate and WorkerWarning model classes |
core/migrations/0009_worker_certificates_warnings.py |
Create (auto) | Schema changes |
core/admin.py |
Modify | Register new models; add inlines to WorkerAdmin |
core/forms.py |
Modify | WorkerForm, WorkerCertificateFormSet, WorkerWarningFormSet |
core/views.py |
Modify | 6 new views: worker_list, worker_detail, worker_edit, worker_batch_report, worker_batch_report_csv, worker_batch_report_pdf |
core/urls.py |
Modify | 6 new routes |
core/templates/core/workers/list.html |
Create | Worker list with search + filters + "Add Worker" button |
core/templates/core/workers/edit.html |
Create | Section-based edit form with inline certs + warnings |
core/templates/core/workers/detail.html |
Create | Read-only worker profile with history tabs |
core/templates/core/workers/batch_report.html |
Create | HTML batch report (extends base.html) |
core/templates/core/pdf/workers_report_pdf.html |
Create | PDF version of batch report (uses WeasyPrint) |
core/templates/base.html |
Modify | Add "Workers" nav link (admin only) |
CLAUDE.md |
Modify | Document new models, URLs, admin patterns |
Data Model
WorkerCertificate
class WorkerCertificate(models.Model):
"""A certification held by a worker (Skills, PDP, First Aid, etc.).
One row per (worker, cert_type) — existence of the row means the
worker currently holds this certification. Delete the row to record
that they no longer hold it. Use `valid_until` to track expiry.
"""
CERT_TYPES = [
('skills', 'Skills Certificate'),
('pdp', 'PDP (Professional Driving Permit)'),
('first_aid', 'First Aid'),
('medical', 'Medical'),
('work_at_height', 'Work at Height'),
]
worker = models.ForeignKey(
Worker, related_name='certificates', on_delete=models.CASCADE,
)
cert_type = models.CharField(max_length=30, choices=CERT_TYPES)
document = models.FileField(
upload_to='workers/certificates/', blank=True, null=True,
help_text='Scan or photo of the certificate',
)
issued_date = models.DateField(blank=True, null=True)
valid_until = models.DateField(
blank=True, null=True,
help_text='Expiry date — leave blank if the cert does not expire',
)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [('worker', 'cert_type')]
ordering = ['worker', 'cert_type']
def __str__(self):
return f'{self.worker.name} — {self.get_cert_type_display()}'
@property
def is_expired(self):
if not self.valid_until:
return False
return self.valid_until < timezone.now().date()
@property
def expires_soon(self):
"""True if the cert expires within the next 30 days."""
if not self.valid_until:
return False
today = timezone.now().date()
return today <= self.valid_until <= today + datetime.timedelta(days=30)
WorkerWarning
class WorkerWarning(models.Model):
"""A disciplinary warning issued to a worker."""
SEVERITY_CHOICES = [
('verbal', 'Verbal Warning'),
('written', 'Written Warning'),
('final', 'Final Warning'),
]
worker = models.ForeignKey(
Worker, related_name='warnings', on_delete=models.CASCADE,
)
date = models.DateField(default=timezone.now)
severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES)
reason = models.CharField(
max_length=200,
help_text='Short summary — e.g. "Repeated lateness"',
)
description = models.TextField(
blank=True,
help_text='Full context of what happened',
)
issued_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name='warnings_issued',
)
document = models.FileField(
upload_to='workers/warnings/', blank=True, null=True,
help_text='Signed warning form (optional)',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-date']
def __str__(self):
return f'{self.worker.name} — {self.get_severity_display()} ({self.date})'
Migration
python manage.py makemigrations core → produces 0009_worker_certificates_warnings.py.
Safe migration: two new tables, no changes to existing ones. Reversible.
URL Routes (new)
Add to core/urls.py before the # === EXPENSE RECEIPTS === section:
# === WORKERS ===
# Admin-friendly worker management UI (alternative to /admin/core/worker/)
path('workers/', views.worker_list, name='worker_list'),
path('workers/new/', views.worker_edit, name='worker_new'),
path('workers/<int:worker_id>/', views.worker_detail, name='worker_detail'),
path('workers/<int:worker_id>/edit/', views.worker_edit, name='worker_edit'),
# Batch report (table of all workers with aggregated history)
path('workers/report/', views.worker_batch_report, name='worker_batch_report'),
path('workers/report/csv/', views.worker_batch_report_csv, name='worker_batch_report_csv'),
path('workers/report/pdf/', views.worker_batch_report_pdf, name='worker_batch_report_pdf'),
Views (new)
All admin-gated via @login_required + is_admin(request.user) → 403 if not admin.
worker_list(request)
- Fetches all Workers (not just active).
- Filters:
?q=search_term,?status=active|inactive|all. - Template:
core/workers/list.html. - Shows table: name, ID, phone, salary, days worked, active toggle.
worker_detail(request, worker_id)
- Worker profile (read-only).
- Shows: personal info, PPE, license, certs (with expiry highlights), warnings, and a "History" tab with projects/teams/days/payslips.
- Template:
core/workers/detail.html.
worker_edit(request, worker_id=None)
- GET: renders form pre-filled (or blank if
worker_id is None). - POST: validates + saves Worker + inline formsets for certs + warnings.
- Redirect on success: →
worker_detail. - Template:
core/workers/edit.html.
worker_batch_report(request)
- Builds per-worker aggregates using a shared
_build_worker_report_context()helper (parallel to_build_report_context). - Filters:
?status=,?project=,?team=. - Template:
core/workers/batch_report.html.
worker_batch_report_csv(request)
- Same context builder; streams a CSV with all columns.
worker_batch_report_pdf(request)
- Same context builder; uses
render_to_pdf('core/pdf/workers_report_pdf.html', context).
Shared helper _build_worker_report_context(status=None, project_id=None, team_id=None)
Returns a list of dicts, one per worker:
{
'worker': worker_obj,
'projects': ['Solar Farm Alpha', 'Solar Farm Beta'], # distinct project names
'teams': ['Team Alpha'], # distinct team names
'days_worked': 47, # distinct WorkLog dates
'first_payslip_date': date(2025, 3, 14) or None,
'last_payslip_date': date(2026, 4, 5) or None,
'total_paid_lifetime': Decimal('127450.00'),
'payslip_count': 12,
'active_certs': 3,
'expiring_certs': 1, # expires within 30 days
'expired_certs': 0,
'active_warnings_count': 1, # warnings issued in last 12 months
}
Aggregation approach (efficient — one query per aggregate, not per worker):
qs = Worker.objects.annotate(
days_worked=Count('work_logs__date', distinct=True),
first_payslip_date=Min('payroll_records__date'),
last_payslip_date=Max('payroll_records__date'),
total_paid_lifetime=Sum('payroll_records__amount_paid'),
payslip_count=Count('payroll_records', distinct=True),
)
# then a separate prefetch for projects/teams
Forms (new)
# core/forms.py
class WorkerForm(forms.ModelForm):
"""Main worker edit form — covers the flat fields on Worker."""
class Meta:
model = Worker
fields = [
'name', 'id_number', 'phone_number', 'monthly_salary',
'employment_date', 'active', 'notes',
'shoe_size', 'overall_top_size', 'pants_size', 'tshirt_size',
'photo', 'id_document',
'has_drivers_license', 'drivers_license',
]
widgets = {
'employment_date': forms.DateInput(attrs={'type': 'date'}),
'notes': forms.Textarea(attrs={'rows': 3}),
}
WorkerCertificateFormSet = inlineformset_factory(
Worker, WorkerCertificate,
fields=['cert_type', 'document', 'issued_date', 'valid_until', 'notes'],
extra=0, # no blank rows by default
can_delete=True,
widgets={
'issued_date': forms.DateInput(attrs={'type': 'date'}),
'valid_until': forms.DateInput(attrs={'type': 'date'}),
'notes': forms.Textarea(attrs={'rows': 2}),
},
)
WorkerWarningFormSet = inlineformset_factory(
Worker, WorkerWarning,
fields=['date', 'severity', 'reason', 'description', 'document'],
extra=0,
can_delete=True,
widgets={
'date': forms.DateInput(attrs={'type': 'date'}),
'description': forms.Textarea(attrs={'rows': 3}),
},
)
In the edit view, request.POST/request.FILES flow through all three (form + two formsets); all must be valid before saving.
Templates (new)
core/workers/list.html
- Search box (name/ID) + status filter dropdown
- Table columns: Name, ID, Phone, Salary, Days Worked, Active, Actions (View / Edit / Toggle)
- Buttons: "Add Worker", "Batch Report", "Export CSV"
- Styled with existing stat-card / resource-row patterns from index.html
core/workers/edit.html
- Section-based layout (no tabs — long-form scroll for easier visual review):
- Personal & Pay — name, id_number, phone, salary, employment_date, active, notes
- PPE Sizing — shoe, overall top, pants, t-shirt
- Documents — photo, id_document
- Driver's License — has_drivers_license, drivers_license file
- Certifications (formset with + add button, × delete)
- Warnings & Disciplinary (formset with + add button, × delete)
- Client-side JS: "Add Certification" / "Add Warning" buttons clone a hidden blank formset row and bump the TOTAL_FORMS counter (standard Django formset pattern)
- Submit button at the bottom; Cancel goes back to
worker_detail
core/workers/detail.html
- Header: worker photo, name, ID, active badge
- Tabs:
- Profile — personal, PPE, license info
- Certifications — list with colored badges: green (valid > 30 days), amber (expires within 30), red (expired)
- Warnings — chronological list
- History — projects worked, teams, days, last 10 payslips
- "Edit" button links to
worker_edit
core/workers/batch_report.html
- Report header + filter bar (status / project / team)
- Table with columns:
- Name | ID | Salary | Active | Days Worked | Projects | Teams | First Payslip | Last Payslip | Total Paid | Certs (n/m) | Warnings
- "Export CSV" + "Download PDF" buttons at top-right
- Row click →
worker_detail
core/pdf/workers_report_pdf.html
- Print-optimized A4 layout using WeasyPrint
- Header: "FoxFitt Construction — Worker Roster Report"
- Filter summary subhead
- Table (narrower columns, landscape orientation may be needed for many fields)
- Uses the same amber accent and typography as
report_pdf.html
Navigation
base.html desktop topbar: add a "Workers" link after "Receipts" and before "Admin" (admin-only):
{% if user.is_staff %}
<a href="{% url 'worker_list' %}" class="topbar-nav__link {% if 'worker' in request.resolver_match.url_name %}active{% endif %}">
<i class="fas fa-hard-hat"></i><span>Workers</span>
</a>
{% endif %}
Also add matching entries to the mobile menu (the .mobile-menu__nav block) and the bottom tab bar (if room).
Django Admin Enhancements
Register the new models:
# core/admin.py
class WorkerCertificateInline(admin.TabularInline):
model = WorkerCertificate
extra = 0
class WorkerWarningInline(admin.TabularInline):
model = WorkerWarning
extra = 0
readonly_fields = ['created_at']
@admin.register(WorkerCertificate)
class WorkerCertificateAdmin(admin.ModelAdmin):
list_display = ('worker', 'cert_type', 'valid_until', 'is_expired')
list_filter = ('cert_type',)
search_fields = ('worker__name',)
@admin.register(WorkerWarning)
class WorkerWarningAdmin(admin.ModelAdmin):
list_display = ('worker', 'date', 'severity', 'reason')
list_filter = ('severity',)
search_fields = ('worker__name', 'reason')
Then update WorkerAdmin to include the inlines:
class WorkerAdmin(admin.ModelAdmin):
# ...existing config...
inlines = [WorkerCertificateInline, WorkerWarningInline]
This means the Django admin ALSO gets the new sections — the in-app edit page is a better UX, but admin remains fully functional.
Task-by-Task Execution Plan
Each task is 5–15 minutes. Execute in order.
Task 1: Add the two new models + migration
Files:
- Modify:
core/models.py(appendWorkerCertificate,WorkerWarningclasses at end of file) - Create:
core/migrations/0009_worker_certificates_warnings.py(auto-generated)
Steps:
- Add the two model classes (code above)
USE_SQLITE=true python manage.py makemigrations coreUSE_SQLITE=true python manage.py migrate- Verify:
python manage.py check→ no issues - Commit
Task 2: Register models in Django admin
Files:
- Modify:
core/admin.py
Steps:
- Add
WorkerCertificateInline,WorkerWarningInline,WorkerCertificateAdmin,WorkerWarningAdminclasses - Add
inlines = [...]toWorkerAdmin - Verify in browser:
/admin/core/worker/<id>/change/shows certs + warnings sections - Commit
Task 3: Add WorkerForm and formsets
Files:
- Modify:
core/forms.py
Steps:
- Add
WorkerForm,WorkerCertificateFormSet,WorkerWarningFormSet - Verify:
python manage.py shell→ import forms → no errors - Commit
Task 4: Add worker_list view and template
Files:
- Modify:
core/views.py(+importupdates) - Modify:
core/urls.py - Create:
core/templates/core/workers/list.html
Steps:
- Add URL route
workers/→views.worker_list - Add view
worker_list(request)with search + status filter - Create template with search bar, table, action buttons
- Verify: visit
/workers/as admin → list shows workers - Commit
Task 5: Add worker_edit view + template (the big one)
Files:
- Modify:
core/views.py - Modify:
core/urls.py - Create:
core/templates/core/workers/edit.html
Steps:
- Add two URL routes:
workers/new/,workers/<id>/edit/ - Add view
worker_edit(request, worker_id=None)handling both create and update - Create section-based template with formsets for certs + warnings
- Add JS for "+ Add Certification" / "+ Add Warning" buttons (formset clone pattern)
- Verify: add a worker, edit a worker, add cert, add warning → all persist
- Commit
Task 6: Add worker_detail view + template
Files:
- Modify:
core/views.py - Modify:
core/urls.py - Create:
core/templates/core/workers/detail.html
Steps:
- Add URL
workers/<id>/ - Add view with tab-context (profile, certs, warnings, history)
- Template with Bootstrap tabs; cert badges styled by expiry
- Link "Edit" button to edit view
- Verify
- Commit
Task 7: Add batch report (HTML, CSV, PDF)
Files:
- Modify:
core/views.py(add_build_worker_report_context, 3 views) - Modify:
core/urls.py - Create:
core/templates/core/workers/batch_report.html - Create:
core/templates/core/pdf/workers_report_pdf.html
Steps:
- Write
_build_worker_report_context()helper with the annotate/prefetch pattern - Add 3 URLs + 3 views
- Create HTML template with filter bar + table
- Create PDF template (derive from existing
report_pdf.htmlstructure — cover, section headings, ledger-style table) - Verify HTML, CSV, PDF all render correctly
- Commit
Task 8: Add nav links
Files:
- Modify:
core/templates/base.html
Steps:
- Add desktop nav link (admin-only)
- Add mobile menu link
- Verify on desktop and mobile layouts
- Commit
Task 9: Update CLAUDE.md
Files:
- Modify:
CLAUDE.md
Steps:
- Add
WorkerCertificateandWorkerWarningto Key Models section - Add 7 new URL routes to URL Routes table
- Add "Worker Management" subsection under Development Workflow
- Commit
Task 10: Verification pass
- Visit every new page; click every button; upload a test file to each upload field
- Try edge cases: blank forms, duplicate cert_type for same worker (should fail unique constraint), expired certs
- Regenerate PDF; open to verify layout
- Run
python manage.py check - Smoke-test existing features (payroll report, payment, receipt) still work
Open Questions for User
The following decisions need your input before I start executing. Defaults in brackets are what I'll use if you don't answer.
Q1: Certification types list
I'm proposing these 5 types:
- Skills Certificate
- PDP (Professional Driving Permit)
- First Aid
- Medical
- Work at Height
Any additions, removals, or renames? [Default: use exactly these 5]
Q2: Warning severity levels
I'm proposing: Verbal → Written → Final. Accept, or add a fourth (e.g. "Informal"), or use different names? [Default: Verbal/Written/Final]
Q3: Navigation placement
Where does the "Workers" link go?
- (a) Top desktop nav, admin-only, after Receipts — prominent, 1-click access
- (b) Only accessible from a button on the Dashboard — less cluttered nav
- (c) Inside the Admin dropdown / submenu
[Default: (a) — matches how Payroll is currently linked]
Q4: Worker list — default sort?
- (a) Alphabetical by name
- (b) By employment date (newest first)
- (c) By active status, then name
[Default: (a) alphabetical]
Q5: Coexist with Django admin?
The new worker list/edit pages would be more user-friendly, but Django admin at /admin/core/worker/ remains fully functional.
Keep Django admin working as a fallback for power-user edits? [Default: yes — keep both]
Q6: Cert expiry alerts on dashboard
Would you like the Dashboard to show a stat card like "3 certs expiring in 30 days"?
- (a) Yes, show it — helpful operationally
- (b) No, keep dashboard unchanged for now
- (c) Show, but only if count > 0 (hide the card when everything's fine)
[Default: (c) conditional display]
Q7: File upload size limit
Certificates and warnings support file uploads. Currently no limit. Should I add a max size to prevent someone accidentally uploading a 50MB scan?
- (a) No limit
- (b) 5 MB max
- (c) 10 MB max
[Default: (b) 5 MB]
Q8: Batch report columns
I'm proposing this column set: Name | ID | Salary | Active | Days Worked | Projects | Teams | First Payslip | Last Payslip | Total Paid | Certs (active/total) | Warnings
Anything to add or remove? Common additions could be: employment_date, phone, has_drivers_license, last-active-date.
[Default: use the list above]
Q9: Scope bailout
If I discover during execution that any task is significantly more complex than the estimate (e.g. Task 5 turns out to need 2 hours instead of 30 minutes), should I:
- (a) Keep going, add a new task, push complete
- (b) Stop, flag the issue, let you decide
- (c) Deliver a smaller version (e.g. certs-only, no warnings) and flag warnings as follow-up
[Default: (b) stop and flag]
Verification (end-to-end)
Run after all tasks complete:
USE_SQLITE=true python manage.py check
USE_SQLITE=true python manage.py migrate --plan # confirm no pending migrations
Browser smoke tests (as admin):
- Visit
/workers/— list renders, search works - Click "Add Worker" — blank form loads
- Fill name/ID/salary, click Save → redirected to detail view
- Click "Edit" on detail — form pre-filled
- Click "+ Add Certification" → blank cert row appears
- Fill cert (Medical, valid_until=next month), upload a test PDF, Save
- Verify cert appears on detail page with green "valid" badge
- Click "+ Add Warning", fill, Save — verify it appears on Warnings tab
- Visit
/workers/report/— table shows all workers with aggregates - Click "Export CSV" — downloads, opens in Excel/LibreOffice cleanly
- Click "Download PDF" — renders with correct layout
- Visit
/admin/core/worker/<id>/change/— Django admin still works, inlines show - As a non-admin user, visit
/workers/— should get 403
Rollback
The change touches two new models with ForeignKey to Worker. If we need to undo:
git reset --hardto the last commit before this plan startedpython manage.py migrate core 0008(the pre-existing migration) — drops the two new tables- Branches stay isolated until merged; worst case the whole thing sits on a local branch forever
Estimated Timeline
- Task 1 (models + migration): 10 min
- Task 2 (admin): 5 min
- Task 3 (forms): 10 min
- Task 4 (list view): 15 min
- Task 5 (edit view + template): 40 min — biggest task; has formset JS
- Task 6 (detail view): 20 min
- Task 7 (batch report): 30 min
- Task 8 (nav): 5 min
- Task 9 (CLAUDE.md): 5 min
- Task 10 (verification): 15 min
Total: ~2.5 hours of supervised execution, or ~90 minutes if I can auto-execute with good test coverage.