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>
40 KiB
FoxFitt LabourPay v5
Coding Style
- Always add clear section header comments using the format: # === SECTION NAME ===
- Add plain English comments explaining what complex logic does
- The project owner is not a programmer — comments should be understandable by a non-technical person
- When creating or editing code, maintain the existing comment structure
Project Overview
Django payroll management system for FoxFitt Construction, a civil works contractor specializing in solar farm foundation installations. Manages field worker attendance, payroll processing, employee loans, and business expenses for solar farm projects.
This is v5 — a fresh export from Flatlogic/AppWizzy, rebuilt from the v2 codebase with simplified models and cleaner structure.
Tech Stack
- Django 5.2.7, Python 3.13, MySQL (production on Flatlogic Cloud Run) / SQLite (local dev)
- Bootstrap 5.3.3 (CDN), Font Awesome 6.5.1 (CDN), Google Fonts (Inter + Poppins)
- WeasyPrint for PDF generation (payroll report, payslips, receipts) — migrated from xhtml2pdf; browser-grade HTML/CSS rendering with flexbox, grid, @font-face, shadows, and proper CSS cascade
- Gmail SMTP for automated document delivery
- Hosted on Flatlogic/AppWizzy platform (Apache on Debian, e2-micro VM)
Project Structure
config/ — Django project settings, URLs, WSGI/ASGI
core/ — Single main app: ALL business logic, models, views, forms, templates
context_processors.py — Injects deployment_timestamp (cache-busting), Flatlogic branding vars
forms.py — AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm + formset
models.py — All 10 database models
utils.py — render_to_pdf() helper (lazy WeasyPrint import + Windows GTK3 DLL registration)
views.py — All view functions (~52 functions, ~3,800 lines) — dashboard, attendance, payroll, reports, worker/team/project CRUD
forms.py — All form classes + validators (WorkerForm, TeamForm, ProjectForm, AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm, WorkerCertificate/WarningFormSet, 5MB file validator)
admin.py — Django admin registrations for all core models + WorkerCertificate/Warning inlines on Worker
templatetags/ — format_tags.py (money filter for ZAR formatting)
management/commands/ — setup_groups, setup_test_data, import_production_data
templates/
base.html — App shell (topbar + mobile menu + bottom tab bar)
core/ — Page templates: index, attendance_log, work_history, payroll_dashboard,
report, create_receipt, payslip, login, _report_config_modal (partial)
core/workers/ — 4 templates: list, detail, edit, batch_report
core/teams/ — 4 templates: list, detail, edit, batch_report
core/projects/— 4 templates: list, detail, edit, batch_report
core/pdf/ — 4 PDF templates: report_pdf, payslip_pdf, receipt_pdf, workers_report_pdf
core/email/ — 2 HTML email templates
admin/ — base_site.html override (adds admin CSS tweaks, e.g. taller M2M pickers)
ai/ — Flatlogic AI proxy client (not used in app logic)
static/css/ — custom.css (CSS variables, component styles, tooltip overrides)
staticfiles/ — Collected static assets (Bootstrap, admin)
Key Models
- UserProfile — extends Django User (OneToOne); minimal, no extra fields in v5
- Project — work sites with supervisor assignments (M2M User), start/end dates, active flag
- Worker — profiles with salary,
daily_rateproperty (monthly_salary / 20), photo, ID doc, PPE sizing (shoe, overall top, pants, tshirt), drivers license (boolean + file upload) - Team — groups of workers under a supervisor, with optional pay schedule (
pay_frequency: weekly/fortnightly/monthly,pay_start_date: anchor date) - WorkLog — daily attendance: date, project, team, workers (M2M), supervisor, overtime,
priced_workers(M2M) - PayrollRecord — completed payments linked to WorkLogs (M2M) and Worker (FK)
- PayrollAdjustment — bonuses, deductions, overtime, loans; linked to project (FK, optional), worker, and optionally to a Loan or WorkLog
- Loan — worker loans AND advances with principal, remaining_balance,
loan_type('loan' or 'advance') - ExpenseReceipt / ExpenseLineItem — business expense records with VAT handling
- WorkerCertificate — per-worker certifications (Skills, PDP, First Aid, Medical, Work at Height) with
valid_untilexpiry and optional document upload. Unique per (worker, cert_type). Hasis_expiredandexpires_soon(≤30 days) properties. - WorkerWarning — disciplinary records per worker with severity (verbal/written/final), reason, optional document. Ordered -date.
Key Business Rules
- All business logic lives in the
core/app — do not create additional Django apps - Workers have a
daily_rateproperty:monthly_salary / Decimal('20.00') - Admin =
is_stafforis_superuser(checked viais_admin(user)helper in views.py) - Supervisors see only their assigned projects, teams, and workers
- Admins have full access to payroll, adjustments, and resource management
- WorkLog is the central attendance record — links workers to projects on specific dates
- Attendance logging includes conflict detection (prevents double-logging same worker+date+project)
- Loans have automated repayment deductions during payroll processing
- Cascading deletes use SET_NULL for supervisors/teams to preserve historical data
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 - DEDUCTIVE_TYPES =
['Deduction', 'Loan Repayment', 'Advance Repayment']— decrease net pay
PayrollAdjustment Type Handling
- Bonus / Deduction — standalone, require a linked Project
- New Loan — creates a
Loanrecord (loan_type='loan'); has a "Pay Immediately" checkbox (checked by default) that auto-processes the loan (creates PayrollRecord, sends payslip to Spark, marks as paid). When unchecked, the loan sits in Pending Payments for the next pay cycle. Editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments - Advance Payment — auto-processed immediately (never sits in Pending): creates
Loan(loan_type='advance'), creates PayrollRecord, sends payslip email, and auto-creates an "Advance Repayment" for the next salary. Requires a Project (for cost tracking) and at least one unpaid work log (otherwise use New Loan). - Overtime — links to
WorkLogviaadj.work_logFK; managed byprice_overtime()view - Loan Repayment — links to
Loan(loan_type='loan') viaadj.loanFK; loan balance changes during payment processing - Advance Repayment — auto-created when an advance is paid; deducts from advance balance during
process_payment(). If partial repayment, remaining balance converts advance to regular loan (loan_typechanges from 'advance' to 'loan'). Editable by admin (amount can be reduced before payday).
Outstanding Payments Logic (Dashboard)
The dashboard's outstanding amount uses per-worker checking, not per-log:
- For each WorkLog, get the set of
paid_worker_idsfrom linked PayrollRecords - A worker is "unpaid for this log" only if their ID is NOT in that set
- This correctly handles partially-paid logs (e.g., one worker paid, another not)
- Unpaid adjustments: additive types increase outstanding, deductive types decrease it
Commands
# Local development (SQLite)
set USE_SQLITE=true && python manage.py runserver 0.0.0.0:8000
# Or use run_dev.bat which sets the env var
python manage.py migrate # Apply database migrations
python manage.py setup_groups # Create Admin + Work Logger permission groups
python manage.py setup_test_data # Populate sample workers, projects, logs
python manage.py import_production_data # Import real production data (14 workers)
python manage.py collectstatic # Collect static files for production
python manage.py check # System check
Development Workflow
- Active development branch:
ai-dev(PR target:master) - Local dev uses SQLite: set
USE_SQLITE=trueenvironment variable - Preview server config:
.claude/launch.json→ runsrun_dev.bat - Admin check in views:
is_admin(request.user)helper (top of views.py) - "Unpaid" adjustment =
payroll_record__isnull=True(no linked PayrollRecord) - POST-only mutation views pattern: check
request.method != 'POST'→ redirect - Template UI: Bootstrap 5 modals with dynamic JS, data-* attributes on clickable elements
- Atomic transactions:
process_payment()usesselect_for_update()to prevent duplicate payments - Payslip Preview modal ("Worker Payment Hub"): shows earnings, adjustments, net pay, plus active loans/advances with inline repayment forms. Uses
refreshPreview()JS function that re-fetches after AJAX repayment submission. Repayment POSTs toadd_repayment_ajaxwhich creates a PayrollAdjustment (balance deduction only happens duringprocess_payment()) - Advance Payment auto-processing:
add_adjustmentimmediately creates PayrollRecord + sends payslip when an advance is created. Also auto-creates an "Advance Repayment" adjustment for the next salary cycle. Uses_send_payslip_email()helper (shared withprocess_payment) - Advance-to-loan conversion: When an Advance Repayment is only partially paid,
process_paymentchanges the Loan'sloan_typefrom 'advance' to 'loan' so the remainder is tracked as a regular loan - Split Payslip: Preview modal has checkboxes on work logs and adjustments (all checked by default).
process_payment()accepts optionalselected_log_ids/selected_adj_idsPOST params to pay only selected items. Falls back to "pay all" if no IDs provided (backward compatible with the quick Pay button). - Team Pay Schedules: Teams have optional
pay_frequency+pay_start_datefields.get_pay_period(team)calculates current period boundaries by stepping forward from the anchor date. The preview modal shows a "Split at Pay Date" button that auto-unchecks items after thecutoff_date(end of last completed period — includes ALL overdue work, not just one period).get_worker_active_team(worker)returns the worker's first active team. - Pay period calculation:
pay_start_dateis an anchor (never needs updating). Weekly=7 days, Fortnightly=14 days, Monthly=calendar month stepping. Usescalendar.monthrange()for month-length edge cases (nodateutildependency). - Batch Pay: "Batch Pay" button on payroll dashboard opens a modal with two radio modes — "Until Last Paydate" (default, splits at last completed pay period per team schedule) and "Pay All" (includes all unpaid items regardless of date). Preview fetches from
batch_pay_previewwith?mode=schedule|all. Workers without team pay schedules are skipped in schedule mode but included in Pay All mode.batch_payPOST endpoint processes each worker in independent atomic transactions; emails are sent after all payments complete. Uses_process_single_payment()shared helper (same logic as individualprocess_payment). Modal includes team filter dropdown and 3-option loan filter (All / With loans only / Without loans). - Pending Payments Table: Shows overdue badges (red) for workers with unpaid work from completed pay periods, and loan badges (yellow) for workers with active loans/advances. Filter bar has: team dropdown, "Overdue only" checkbox, and loan dropdown (All Workers / With loans only / Without loans). Overdue detection uses
get_pay_period()cutoff logic. - Quick Adjust Button: Each pending payments row has an "Adjust" button (slider icon) that opens the Add Adjustment modal with that worker pre-checked and their most recent project pre-selected. The header "Add Adjustment" button resets the modal to a clean state. Uses
_quickAdjustOpenflag to distinguish between the two open paths. - Worker Lookup Modal: Clicking any worker name on the payroll dashboard (or using the "Worker Lookup" button) opens a modal with a comprehensive report card — amount payable, outstanding loans, paid this month/year, loans this year, recent activity (last payslip, loan, repayment, advance), active loans table, current project + days on project, PPE sizing, drivers license, and notes. Uses
worker_lookup_ajaxAJAX endpoint. Worker dropdown in modal allows switching workers without closing. - Team & Project Management UIs: Friendlier alternatives to
/admin/core/team/and/admin/core/project/. Reachable via the "Resources" dropdown in the topbar (admin only). Team pages:/teams/(list + search/filter),/teams/<id>/(detail with Profile/Pay Schedule/Workers/History tabs — Pay Schedule tab uses the existingget_pay_period()helper to show current + next 2 periods),/teams/<id>/edit/(single-page form for name, supervisor, pay schedule, and workers M2M). Project pages:/projects/,/projects/<id>/(tabs: Profile/Supervisors/Teams/Workers/History),/projects/<id>/edit/(form for name, description, dates, supervisors M2M). UsesTeamFormandProjectFormfromcore/forms.py(both simple ModelForms, no inline formsets). Batch reports at/teams/report/and/projects/report/with CSV exports; PDF exports deferred as a follow-up. Dashboard "Manage Resources" card now has "Manage All Workers/Projects/Teams" footer links on each tab. Django admin remains fully functional as a fallback. - Worker Management UI: A friendlier alternative to
/admin/core/worker/. Reachable via the "Resources" topbar dropdown → Workers (admin-only). Pages:/workers/(list with search + status filter),/workers/<id>/(detail with Profile/Certifications/Warnings/History tabs),/workers/<id>/edit/or/workers/new/(single-page form with sections for Personal & Pay, PPE, Documents, Driver's License, plus inline formsets for certifications and warnings). UsesWorkerForm,WorkerCertificateFormSet,WorkerWarningFormSetfromcore/forms.py. The "+ Add Certification" / "+ Add Warning" buttons clone a<template>element viacontent.cloneNode()(DOM-safe, no innerHTML) and rewrite__PREFIX__in input names to the next formset index. File uploads validated at 5 MB max viavalidate_max_5mb()informs.py. Django admin (/admin/core/worker/) remains fully functional as a fallback — both UIs coexist. - Worker Batch Report:
/workers/report/shows every worker with aggregated lifetime history — days worked, projects worked on, teams, first/last payslip dates, total paid, cert status (active/total + expired/expiring counts), warning count. Filter by status, project, team. CSV export via/workers/report/csv/, PDF via/workers/report/pdf/(landscape A4, same amber-accent typography as the payroll report). Built on the reusable_build_worker_report_context()helper which usesannotate(Min/Max/Count/Sum)+ prefetch for efficient aggregation. - Dashboard cert-expiry card: The admin dashboard shows a "Certifications Need Attention" stat card with count of expired + expiring-within-30-days certs (active workers only). Card is CONDITIONAL — renders only when count > 0, so it disappears when everything is in good standing. Clicking it goes to the worker batch report. Counts come from
index()view addingcerts_expired_count,certs_expiring_count,certs_alert_totalto context.
URL Routes
| Path | View | Purpose |
|---|---|---|
/ |
index |
Dashboard (admin stats / supervisor work view) |
/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 |
/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 |
/workers/<id>/ |
worker_detail |
Admin: worker profile with profile/certs/warnings/history tabs |
/workers/<id>/edit/ |
worker_edit |
Admin: edit worker + inline cert/warning formsets |
/workers/report/ |
worker_batch_report |
Admin: aggregated roster report (days, projects, payslips, certs) |
/workers/report/csv/ |
worker_batch_report_csv |
Admin: batch report as CSV download |
/workers/report/pdf/ |
worker_batch_report_pdf |
Admin: batch report as PDF download |
/teams/ |
team_list |
Admin: friendly team list with search + status filter |
/teams/new/ |
team_edit |
Admin: blank team-create form |
/teams/<id>/ |
team_detail |
Admin: team profile with profile/pay schedule/workers/history tabs |
/teams/<id>/edit/ |
team_edit |
Admin: edit team (name, supervisor, pay schedule, workers M2M) |
/teams/report/ |
team_batch_report |
Admin: aggregated team report (HTML) |
/teams/report/csv/ |
team_batch_report_csv |
Admin: team batch report as CSV download |
/projects/ |
project_list |
Admin: friendly project list with search + status filter |
/projects/new/ |
project_edit |
Admin: blank project-create form |
/projects/<id>/ |
project_detail |
Admin: project profile with profile/supervisors/teams/workers/history tabs |
/projects/<id>/edit/ |
project_edit |
Admin: edit project (name, description, dates, supervisors M2M) |
/projects/report/ |
project_batch_report |
Admin: aggregated project report (HTML) |
/projects/report/csv/ |
project_batch_report_csv |
Admin: project batch report as CSV download |
/toggle/<model>/<id>/ |
toggle_active |
Admin: AJAX toggle active status |
/payroll/ |
payroll_dashboard |
Admin: pending payments, loans, charts |
/payroll/pay/<worker_id>/ |
process_payment |
Admin: process payment (atomic) |
/payroll/price-overtime/ |
price_overtime |
Admin: AJAX price unpriced OT entries |
/payroll/adjustment/add/ |
add_adjustment |
Admin: create adjustment |
/payroll/adjustment/<id>/edit/ |
edit_adjustment |
Admin: edit unpaid adjustment |
/payroll/adjustment/<id>/delete/ |
delete_adjustment |
Admin: delete unpaid adjustment |
/payroll/preview/<worker_id>/ |
preview_payslip |
Admin: AJAX JSON payslip preview (includes active loans) |
/payroll/worker-lookup/<worker_id>/ |
worker_lookup_ajax |
Admin: AJAX JSON worker report card |
/payroll/repayment/<worker_id>/ |
add_repayment_ajax |
Admin: AJAX add loan/advance repayment from preview |
/payroll/payslip/<pk>/ |
payslip_detail |
Admin: view completed payslip |
/receipts/create/ |
create_receipt |
Staff: expense receipt with line items |
/import-data/ |
import_data |
Setup: run import command from browser |
/payroll/batch-pay/preview/ |
batch_pay_preview |
Admin: AJAX JSON batch pay preview (?mode=schedule|all) |
/payroll/batch-pay/ |
batch_pay |
Admin: POST process batch payments for multiple workers |
/run-migrate/ |
run_migrate |
Setup: run pending DB migrations from browser |
Frontend Design Conventions
- Dual-theme (dark + light) driven by a single CSS variable set in
static/css/custom.css. The theme is dark-first; the light theme is a set of var overrides inside a:root.lightblock. A sun/moon toggle in the topbar flips a class on<html>and persists the choice to localStorage. - CSS variables in
static/css/custom.css:root— always usevar(--name):--accent: #e8851a(warm orange/amber, brand),--accent-hover: #f59e0b--primary-dark: #0f172a,--primary: #1e293b--bg-card: #161921,--bg-card-hover: #1c2029(elevated surfaces)--text-primary: #d8d8d8(dark theme),--text-secondary,--text-tertiary- Light-theme overrides flip backgrounds to white/grey and accent to
#d97706
- Icons: Font Awesome 6 only (
fas fa-*). Do NOT use Bootstrap Icons (bi bi-*) - CTA buttons:
btn-accent(orange) for primary actions.btn-primary(dark slate) for modal Save/Submit - Page titles:
{% block title %}Page Name | FoxFitt{% endblock %} - Fonts: Inter (body) + Poppins (headings) loaded in base.html via Google Fonts CDN
- Cards: Borderless with subtle shadow. Stat cards have coloured accent bars on the left.
- Bootstrap tooltips: Global init in
base.html— any element withdata-bs-toggle="tooltip" title="..."gets a tooltip automatically. Tooltips are themed via custom--bs-tooltip-bg/--bs-tooltip-coloroverrides in custom.css so they're readable in both light and dark modes (otherwise Bootstrap's default picks the wrong pair of body vars for dark mode). - Email/PDF payslips: Worker name dominant — do NOT add prominent FoxFitt branding
PDF Generation (WeasyPrint)
Migrated from xhtml2pdf in Nov 2026. WeasyPrint is a browser-grade HTML→PDF renderer — it supports real CSS (flexbox, grid, @font-face, shadows, border-radius, proper cascade) that xhtml2pdf could not handle.
Files
core/utils.py—render_to_pdf(template_src, context_dict)is the single entry point; lazy-imports WeasyPrint, returns PDF bytes orNoneon failurecore/templates/core/pdf/report_pdf.html— payroll report (complex layout, 600+ lines)core/templates/core/pdf/payslip_pdf.html— payslip (emailed to Spark Receipt after each payment)core/templates/core/pdf/receipt_pdf.html— expense receipt (emailed to Spark Receipt after each expense entry)core/templatetags/format_tags.py::money— South African space-separated currency formatting (R 64 939.00); use this instead offloatformat:2
Callers
generate_report_pdf()— downloads the report PDF to the browser_send_payslip_email()— attaches payslip PDF to Gmail SMTP email (called byprocess_payment,add_adjustmentadvance path,batch_pay)create_receipt()— attaches receipt PDF to Gmail SMTP email- All three use the same
EmailMultiAlternatives.attach(filename, pdf_bytes, "application/pdf")pattern — engine-agnostic
Dependencies
- Python package:
weasyprint==68.1(pinned inrequirements.txt) - System libraries (Pango, Cairo, GDK-PixBuf, FFI, shared-mime-info):
- Flatlogic/production (Debian): already installed on the platform image — confirmed via Flatlogic's Gemini
- Windows local dev: install the GTK3 runtime via
winget install -e --id tschoonj.GTKForWindows --accept-package-agreements --accept-source-agreements --silent(installsC:\Program Files\GTK3-Runtime Win64\) - macOS local dev:
brew install pango(not currently used but documented for completeness)
Windows DLL resolution quirk
Since Python 3.8, native DLLs are not loaded from PATH automatically — an explicit os.add_dll_directory() call is required. The _ensure_gtk_on_windows() helper in core/utils.py handles this automatically: it checks common GTK3 install paths on module load and registers the first one found. No-op on Linux/macOS.
If the report page returns "PDF generation failed", check the Django log for the underlying error:
cannot load library 'gobject-2.0-0'→ GTK3 runtime not installed (run the winget command above)'super' object has no attribute 'transform'→weasyprint/pydyfversion mismatch; reinstall withpip install --upgrade weasyprint==68.1
Template conventions
- Modern CSS is fine — flexbox (
display: flex), grid (display: grid; gap: 20pt),@font-face,box-shadow,border-radiusall render correctly - Fonts: WeasyPrint can load web fonts. If we ever add
@font-faceblocks pointing tostatic/fonts/Inter-*.ttfandPoppins-*.ttf, the PDFs can use the same typography as the web app (currently the PDFs use Helvetica by default — upgrading to Inter/Poppins is optional follow-up work) - Page setup:
@page { size: a4 portrait; margin: 2cm 1.8cm 1.6cm 1.8cm; }— standard A4 with generous margins base_url:render_to_pdf()passessettings.STATIC_ROOT or "."asbase_urlso relative paths in<img src="...">and@font-face src: url(...)resolve against the collected static dir
Known lint footguns (legacy from xhtml2pdf era)
report_pdf.htmlstill uses invisible<table class="cols">elements for two-column layout — these work fine under WeasyPrint but could be simplified todisplay: grid; grid-template-columns: 1fr 1fr; gap: 20ptas a future cleanup pass- The
period-detail td { padding-top: 3pt; padding-bottom: 3pt; }split-padding workaround (from a shorthand-collision bug with xhtml2pdf) is no longer needed; safe to replace with thepaddingshorthand when cleaning up the template
Users, Roles & Permissions
Understanding who-can-do-what in this app requires grasping three separate layers of Django auth that stack on top of each other:
Layer 1 — Django's three built-in user flags
These live on the auth.User model and are the foundation. Every user has exactly one
combination of these three flags:
| Flag | What it means | Who should have it |
|---|---|---|
is_superuser=True |
Bypasses every permission check. Full access to everything everywhere, including Django admin. Created by createsuperuser. |
Konrad (the owner), and one emergency-access account. That's it. |
is_staff=True |
Can log into /admin/ (the built-in Django admin interface) and sees it. Does NOT grant any model permissions by itself — those come from groups or per-user permissions. |
Konrad, and any "office admin" people who need full access to edit data via Django admin. Usually combined with is_superuser in this app. |
| (neither) | Regular user. Can log into the friendly app at / but cannot enter Django admin. Sees only what they've been explicitly given access to via group membership or supervisor assignments. |
Site supervisors (Work Loggers), and any future non-admin roles. |
Key mental model: is_superuser beats everything. A superuser's permission groups
and assignments don't matter — they always see everything. Use superuser sparingly
so that regular permission paths get exercised and tested.
Layer 2 — The two app-specific permission groups
Created by python manage.py setup_groups (a one-time command, safe to re-run; it
updates existing groups rather than duplicating them). Assignment happens in
/admin/auth/group/ → add users to groups:
Admin group — grants every Django model permission (add/change/delete/view) on all 10 core models (Worker, Project, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem, and the new WorkerCertificate/ WorkerWarning via separate admin registration).
- Practical effect: only matters for
is_staff=Trueusers who are NOT superusers. For them, the Admin group is what lets them actually use Django admin (without it, they can log into /admin/ but see empty lists). - For superusers, the Admin group is redundant (they bypass permissions anyway).
- For non-staff users, the Admin group is pointless (they can't reach Django admin at all).
Work Logger group — grants: add/change/view WorkLog; view-only on
Project, Worker, Team. Notably does NOT grant any Payroll permissions.
- Practical effect: this group is the app's signal for "this user is a site
supervisor". The
is_supervisor()helper inviews.pyexplicitly checks for membership in this group — so adding someone here marks them as a supervisor, even if they don't own any teams or projects yet. - Work Loggers typically have
is_staff=False(no Django admin access). They use the friendly app UI at/attendance/log/,/history/, and the dashboard.
Layer 3 — Implicit supervisor roles via model relationships
Two model fields independently grant "supervisor-ness" even without group membership:
Team.supervisor(ForeignKey → User) — whoever this points to is a supervisor of that team. Set on the Team edit page or/admin/core/team/.Project.supervisors(ManyToManyField → User) — every user in this M2M is a supervisor of the project. Set on the Project edit page or/admin/core/project/.
The is_supervisor() helper treats any ONE of these as sufficient:
def is_supervisor(user):
return (
user.supervised_teams.exists() # Team.supervisor FK reverse
or user.assigned_projects.exists() # Project.supervisors M2M reverse
or user.groups.filter(name='Work Logger').exists()
)
So a user can become a supervisor via any of: Work Logger group, assigned to a Team as supervisor, or added to a Project's supervisors M2M.
The three permission-check helpers in core/views.py
All three are defined near the top of views.py (around line 47–67):
| Helper | Returns True if… | Used by |
|---|---|---|
is_admin(user) |
is_staff=True OR is_superuser=True |
Every admin-only view (payroll, reports, worker/team/project management, CSV exports) |
is_supervisor(user) |
Supervises a team OR has assigned projects OR is in Work Logger group | Attendance logging, history page filtering |
is_staff_or_supervisor(user) |
is_admin OR is_supervisor |
Views accessible to both tiers (dashboard shows different content per tier) |
Critical: is_admin() does NOT check for the "Admin" group. It checks the
Django is_staff/is_superuser flags. A user can be in the "Admin" permission
group but NOT be an admin as far as the app is concerned, and vice versa. The
group controls Django-admin model permissions; the flags control everything else.
How views enforce permissions
@login_requiredis on every view exceptimport_data()andrun_migrate()(temporary setup endpoints).- Admin-only views call
is_admin(request.user)at the top and returnHttpResponseForbidden("Admin access required.")if false. Examples: everything under/payroll/,/workers/*,/teams/*,/projects/*,/report/*,/workers/export/. - Supervisor-scoped data uses
is_supervisor()to gate access, then filters querysets by the user'ssupervised_teams/assigned_projects:work_history— supervisors see only logs for their teams/projectsAttendanceLogForm— pre-filtersprojectandteamdropdowns by what the user can see;workersfield is filtered by team membership
- Permission cascading — a supervisor of a Team automatically "supervises" every worker in that team, and every project that team has worked on. This is implicit — there's no per-worker permission.
The "Resources" dropdown supervisor picker
When editing a Team or Project via the friendly UI (/teams/<id>/edit/ or
/projects/<id>/edit/), the Supervisor/Supervisors picker uses
_supervisor_user_queryset() in core/forms.py:
User.objects.filter(is_active=True).filter(
Q(is_staff=True) | Q(is_superuser=True) | Q(groups__name='Work Logger')
).distinct()
So anyone who's either an admin OR a Work Logger shows up as an eligible
supervisor. Deactivated accounts (is_active=False) are hidden.
Typical user setups
| User | is_superuser | is_staff | Groups | Supervised Teams/Projects | What they can do |
|---|---|---|---|---|---|
| admin (Konrad) | ✓ | ✓ | — | — | Everything. Bypasses all checks. |
| testadmin (Flatlogic) | ✓ | ✓ | — | — | Same as above. |
| eendman | — | — | Work Logger | (usually also supervises a team) | Log work for assigned teams/projects, see their history. Cannot enter Django admin. Cannot view payroll or worker salary data via app UI. |
| Office data-entry staff (future) | — | ✓ | Admin | — | Can enter Django admin and CRUD all core models there. Does NOT see the payroll dashboard or worker salary UI (because is_admin() helper still returns True via is_staff, so actually yes they CAN see payroll — see note below). |
| Inactive terminated employee | — | — | — | — | Cannot log in if is_active=False. |
Note on is_staff + payroll access: is_admin() returns True for any
is_staff user. That means if you create an office-admin user with
is_staff=True but no superuser flag, they WILL see payroll and salary data
via the friendly UI. If you need a "Django admin only, no payroll UI" role,
we'd have to add a separate flag or group check — not currently supported.
How to add a new supervisor (step by step)
- Go to
/admin/auth/user/add/and create the user with a username and password. Uncheck "Staff status" on the initial form (they don't need Django admin access). - On the user's change page, add them to the Work Logger group.
- (Optional) Assign them as the supervisor of one or more teams via
/teams/<id>/edit/(Supervisor dropdown — they'll appear in the list because of their Work Logger group membership). - (Optional) Add them to one or more projects via
/projects/<id>/edit/(Supervisors M2M checklist). - They can now log in at
/accounts/login/and will land on the Dashboard with a supervisor view — their teams + projects only.
Common misconceptions (read these)
- "Admin" group ≠ Django admin access. Django admin access requires
is_staff=True. The "Admin" group is just a permission bundle. - Work Logger doesn't need to supervise a team to be "a supervisor".
Group membership alone satisfies
is_supervisor(). - Superuser bypasses the "Admin" group. They don't need it.
- Deactivating a user (
is_active=False) blocks login entirely; their team/project assignments remain in the DB for audit purposes but stop having any effect. - The
supervisorfield on WorkLog is historical, not authoritative. It records who logged the work that day — not who currently supervises the workers.
Authentication
- Django's built-in auth (
django.contrib.auth) - Login:
/accounts/login/→ redirects to/(home) - Logout: POST to
/accounts/logout/→ redirects to login - All views use
@login_requiredexceptimport_data()andrun_migrate() - No PIN auth in v5 (simplified from v2)
- Passwords: Django's default PBKDF2 hashing, no custom password policy
- Sessions: cookie-based, server-side session store (default Django)
Django Admin Customisation
The /admin/ interface is Django's built-in admin with two targeted customisations:
Model registrations (core/admin.py)
- Every core model is registered with
list_display,list_filter,search_fields, and (where relevant)filter_horizontal/filter_verticalfor M2M pickers. WorkerAdminhasWorkerCertificateInline+WorkerWarningInlineso you can edit a worker's certs and warnings inline on the worker change page.WorkerCertificateAdmin+WorkerWarningAdminare also standalone (useful for "show me all expiring certs across all workers" type queries via list_filter).
Template override — core/templates/admin/base_site.html
Extends admin/base.html and injects a small <style> block into every admin page.
Currently used for:
- Taller
FilteredSelectMultiplewidgets (the "Choose" / "Available" boxes used byfilter_horizontalon Groups, WorkLogs etc.) — default Django height is ~16em which is too short for long permission lists; we set 30em (40em on tall screens).
Add more admin-only CSS tweaks inside that <style> block rather than polluting
static/css/custom.css.
Why the override works — TEMPLATES.DIRS setting
Django's template loader tries TEMPLATES[0].DIRS before the app-dirs loader. Since
django.contrib.admin comes before core in INSTALLED_APPS (the standard order),
its admin/base_site.html would normally win. Adding BASE_DIR / 'core' / 'templates'
to TEMPLATES[0]['DIRS'] in config/settings.py makes our override take priority
without reordering INSTALLED_APPS (which would risk subtle side effects on signals,
migrations, and admin URL registration).
Backup & Restore (production safety net)
Flatlogic doesn't expose MySQL directly (no SSH, no mysqldump, no DB console).
Instead, the app ships two management commands + two admin-only browser URLs
that back up and restore every row via Django's ORM — platform-independent,
works anywhere Django does.
Making a backup (before any risky deploy)
From the browser (production):
- Log in as admin
- Visit
/backup-data/ - Browser downloads
foxlog_backup_<timestamp>.jsonto your laptop - Move that file somewhere safe (Google Drive, local disk — NOT the repo)
From the command line (local dev or SSH-able host):
python manage.py backup_data # → backups/foxlog_<timestamp>.json
python manage.py backup_data --output=custom.json # → custom.json
Either method produces an identical JSON file covering:
- All auth tables: User, Group, Permission, ContentType (so accounts restore correctly)
- UserProfile, Project, Worker, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem, WorkerCertificate, WorkerWarning
File size is roughly 1 KB per row (14 workers / 100 work logs → ~90 KB).
Restoring a backup
From the browser:
- Log in as admin
- Visit
/restore-data/ - Upload your
.jsonfile - Tick "Yes, I understand" checkbox
- Click Restore — runs inside a database transaction, all-or-nothing
Behaviour:
- Rows with matching primary key are UPDATED (no duplicates)
- Rows with primary keys not yet in the DB are INSERTED
- Rows in the DB but NOT in the backup are KEPT (restore doesn't delete)
- If any row fails to load, the whole restore is rolled back (no partial state)
For a clean restore (wipe everything first, then load the backup):
python manage.py flush # irreversible — deletes ALL data
python manage.py restore_data backup.json
Recommended workflow before any deploy
/backup-data/on production before pushing the change- Push the change to
ai-dev, let Flatlogic rebuild - Verify the new version works
- If broken: restore from the backup you just took via
/restore-data/ - Delete the backup file from your laptop once you're confident the deploy is stable
Files involved
core/management/commands/backup_data.py— CLI command + reusablebuild_backup_payload()helpercore/management/commands/restore_data.py— CLI command + reusablerestore_from_json_string()helpercore/views.py::backup_data— browser view that reuses the helpercore/views.py::restore_data— browser view with minimal HTML upload UI- URLs:
/backup-data/,/restore-data/(both@login_required+is_admin()gated)
Environment Variables
DJANGO_SECRET_KEY, DJANGO_DEBUG, HOST_FQDN, CSRF_TRUSTED_ORIGIN
DB_NAME, DB_USER, DB_PASS, DB_HOST (default: 127.0.0.1), DB_PORT (default: 3306)
USE_SQLITE # "true" → use SQLite instead of MySQL
EMAIL_HOST_USER, EMAIL_HOST_PASSWORD (Gmail App Password — 16 chars)
DEFAULT_FROM_EMAIL, SPARK_RECEIPT_EMAIL
PROJECT_DESCRIPTION, PROJECT_IMAGE_URL # Flatlogic branding
Flatlogic/AppWizzy Deployment
- Branches:
ai-dev= development (Flatlogic AI + Claude Code).master= deploy target. - Workflow: Push to
ai-dev→ Flatlogic auto-detects → "Pull Latest" → app rebuilds (~5 min) - Deploy from Git (Settings): Full rebuild from
master— use for production - Migrations: Sometimes run automatically during rebuild, but NOT always reliable. If you get "Unknown column" errors after pulling latest, visit
/run-migrate/in the browser to apply pending migrations manually. This endpoint runspython manage.py migrateon the production MySQL database. - Never edit
ai-devdirectly on GitHub — Flatlogic pushes overwrite it - Gemini gotcha: Flatlogic's Gemini AI reads
__pycache__/*.pycand gets confused. Tell it: "Do NOT read .pyc files. Only work with .py source files." - Sequential workflow: Don't edit in Flatlogic and Claude Code at the same time
Security Notes
- Production:
SESSION_COOKIE_SECURE=True,CSRF_COOKIE_SECURE=True,SameSite=None(cross-origin for Flatlogic iframe) - Local dev: Secure cookies disabled when
USE_SQLITE=true - X-Frame-Options middleware disabled (required for Flatlogic preview)
- Email App Password should be in env var, not hardcoded in settings.py
Important Context
- The owner (Konrad) is not a developer — explain changes clearly and avoid unnecessary complexity
- This system handles real payroll for field workers — accuracy is critical
render_to_pdf()uses lazy import of WeasyPrint to prevent app crash if library missing; on Windows it also auto-registers the GTK3 runtime's DLL directory soctypes.find_library()can locategobject-2.0-0(Python 3.8+ requires explicitos.add_dll_directory())- Django admin is available at
/admin/with full model registration and search/filter